fix: tighten sharing and shadcn composition
CI / checks (push) Successful in 12m55s
CI / build (push) Successful in 15m16s

This commit is contained in:
ginnoir
2026-06-13 05:20:01 -05:00
parent e1774c802d
commit a312d4ce39
42 changed files with 1539 additions and 417 deletions
+11 -1
View File
@@ -17,6 +17,15 @@ export type ShareCapabilities = {
defaultCapabilities?: string[];
};
export type ShareContext = {
householdId: string;
userId: string;
};
export type PublicShareContext = {
householdId: string;
};
export type ReminderCapabilities = {
canRemind: boolean;
};
@@ -68,7 +77,8 @@ export type EntityTypeRegistration = {
reminder?: ReminderCapabilities;
search?: SearchAdapter;
resolveUrl: (id: string) => string;
loadForShare?: (id: string) => Promise<unknown>;
canShareEntity?: (id: string, ctx: ShareContext) => Promise<boolean>;
loadForShare?: (id: string, ctx: PublicShareContext) => Promise<unknown>;
renderSharedView?: (props: {
data: unknown;
capabilities: { read: boolean; write: boolean };
+16
View File
@@ -0,0 +1,16 @@
import type { EntityTypeRegistration, ShareContext } from "./module";
export async function ensureEntityShareAuthorized(
registration: EntityTypeRegistration,
entityId: string,
ctx: ShareContext,
): Promise<void> {
if (!registration.canShareEntity) {
throw new Error(`Entity type "${registration.type}" does not support share authorization`);
}
const allowed = await registration.canShareEntity(entityId, ctx);
if (!allowed) {
throw new Error("You are not allowed to share this entity");
}
}
+5
View File
@@ -6,6 +6,7 @@ import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { getEntityType } from "./registry";
import { shareLinks } from "./schema";
import { ensureEntityShareAuthorized } from "./share-authorization";
export type ShareLinkCapabilities = { read: boolean; write: boolean };
@@ -36,6 +37,10 @@ export async function createShareLink(
}
const { user, household } = await getCurrentSession();
await ensureEntityShareAuthorized(registration, entityId, {
householdId: household.id,
userId: user.id,
});
const rawToken = randomBytes(32).toString("base64url");
const tokenHash = hashToken(rawToken);
@@ -17,6 +17,7 @@ import type { CalView } from "@/modules/_core/themes";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
@@ -48,6 +49,10 @@ type EventDraft = {
type MobileView = "day" | "week" | "agenda";
const DEFAULT_COLOR = "#B85C3C";
const VISIBILITY_ITEMS = [
{ label: "Household", value: "household" },
{ label: "Private", value: "private" },
];
const VIEW_MAP: Record<CalView, string> = {
month: "dayGridMonth",
@@ -115,6 +120,10 @@ export function CalendarShell({
() => eventRows.filter((event) => visibleIds.has(event.calendarId)),
[eventRows, visibleIds],
);
const calendarSelectItems = useMemo(
() => calendarRows.map((calendar) => ({ label: calendar.name, value: calendar.id })),
[calendarRows],
);
function toggleCalendar(id: string) {
setVisibleIds((current) => {
@@ -343,6 +352,7 @@ export function CalendarShell({
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
/>
<Select
items={VISIBILITY_ITEMS}
value={calendar.visibility}
onValueChange={(value) =>
updateCalendar(calendar, { visibility: value as "private" | "household" })
@@ -354,8 +364,13 @@ export function CalendarShell({
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="household">Household</SelectItem>
<SelectItem value="private">Private</SelectItem>
<SelectGroup>
{VISIBILITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
@@ -391,6 +406,7 @@ export function CalendarShell({
onChange={(event) => setCalendarColorValue(event.target.value)}
/>
<Select
items={VISIBILITY_ITEMS}
value={calendarVisibility}
onValueChange={(value) =>
setCalendarVisibilityValue(value as "private" | "household")
@@ -402,8 +418,13 @@ export function CalendarShell({
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="household">Household</SelectItem>
<SelectItem value="private">Private</SelectItem>
<SelectGroup>
{VISIBILITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -444,6 +465,7 @@ export function CalendarShell({
/>
<Label htmlFor="event-calendar">Calendar</Label>
<Select
items={calendarSelectItems}
value={selectedEvent.calendarId}
onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
>
@@ -454,11 +476,13 @@ export function CalendarShell({
</SelectValue>
</SelectTrigger>
<SelectContent>
{calendarRows.map((calendar) => (
<SelectItem key={calendar.id} value={calendar.id}>
{calendar.name}
</SelectItem>
))}
<SelectGroup>
{calendarSelectItems.map((calendar) => (
<SelectItem key={calendar.value} value={calendar.value}>
{calendar.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="grid gap-3 sm:grid-cols-2">
+6 -2
View File
@@ -2,6 +2,8 @@ import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod";
import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries";
import {
canShareCalendar,
canShareEvent,
loadCalendarForShare,
loadEventForShare,
type CalendarShareData,
@@ -150,7 +152,8 @@ const manifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchCalendars },
resolveUrl: (id) => `/calendar?id=${id}`,
loadForShare: (id) => loadCalendarForShare(id),
canShareEntity: canShareCalendar,
loadForShare: loadCalendarForShare,
renderSharedView: ({ data }) => <CalendarSharedView data={data as CalendarShareData} />,
renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined;
@@ -166,7 +169,8 @@ const manifest: ModuleManifest = {
reminder: { canRemind: true },
search: { search: searchEvents },
resolveUrl: (id) => `/calendar/events/${id}`,
loadForShare: (id) => loadEventForShare(id),
canShareEntity: canShareEvent,
loadForShare: loadEventForShare,
renderSharedView: ({ data }) => <EventSharedView data={data as EventShareData} />,
renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined;
+48 -4
View File
@@ -1,5 +1,6 @@
import { and, asc, eq, gte, lte } from "drizzle-orm";
import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { calendarEvents, calendars } from "../schema";
export type EventSummary = {
@@ -21,11 +22,51 @@ export type CalendarShareData = {
export type EventShareData = EventSummary & { calendarName: string };
export async function loadCalendarForShare(id: string): Promise<CalendarShareData | null> {
function canSeeCalendarRow(
row: { householdId: string; ownerId: string; visibility: string },
ctx: ShareContext,
): boolean {
if (row.householdId !== ctx.householdId) return false;
return row.visibility === "household" || row.ownerId === ctx.userId;
}
export async function canShareCalendar(id: string, ctx: ShareContext): Promise<boolean> {
const [calendar] = await db
.select({
householdId: calendars.householdId,
ownerId: calendars.ownerId,
visibility: calendars.visibility,
})
.from(calendars)
.where(eq(calendars.id, id))
.limit(1);
return calendar ? canSeeCalendarRow(calendar, ctx) : false;
}
export async function canShareEvent(id: string, ctx: ShareContext): Promise<boolean> {
const [row] = await db
.select({
householdId: calendars.householdId,
ownerId: calendars.ownerId,
visibility: calendars.visibility,
})
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.id, id))
.limit(1);
return row ? canSeeCalendarRow(row, ctx) : false;
}
export async function loadCalendarForShare(
id: string,
ctx: PublicShareContext,
): Promise<CalendarShareData | null> {
const [calendar] = await db
.select({ id: calendars.id, name: calendars.name, color: calendars.color })
.from(calendars)
.where(eq(calendars.id, id))
.where(and(eq(calendars.id, id), eq(calendars.householdId, ctx.householdId)))
.limit(1);
if (!calendar) return null;
@@ -63,7 +104,10 @@ export async function loadCalendarForShare(id: string): Promise<CalendarShareDat
};
}
export async function loadEventForShare(id: string): Promise<EventShareData | null> {
export async function loadEventForShare(
id: string,
ctx: PublicShareContext,
): Promise<EventShareData | null> {
const [row] = await db
.select({
id: calendarEvents.id,
@@ -77,7 +121,7 @@ export async function loadEventForShare(id: string): Promise<EventShareData | nu
})
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.id, id))
.where(and(eq(calendarEvents.id, id), eq(calendars.householdId, ctx.householdId)))
.limit(1);
if (!row) return null;
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
deleteCareSchedule,
scheduleOnCalendar,
@@ -35,7 +35,6 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
const [calendarOpenId, setCalendarOpenId] = useState<string | null>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? "");
const [reminderMinutes, setReminderMinutes] = useState("");
const [calendarSuccess, setCalendarSuccess] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
@@ -56,6 +55,7 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
router.refresh();
} catch {
setFormError("Failed to save schedule.");
toast.error("Failed to save schedule");
}
});
}
@@ -84,10 +84,11 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
reminderMinutesBefore: reminderMinutes ? parseInt(reminderMinutes, 10) : undefined,
});
setCalendarOpenId(null);
setCalendarSuccess(scheduleId);
setTimeout(() => setCalendarSuccess(null), 4000);
toast.success("Event added to calendar");
} catch (err) {
setFormError(err instanceof Error ? err.message : "Failed to add to calendar.");
const message = err instanceof Error ? err.message : "Failed to add to calendar.";
setFormError(message);
toast.error(message);
}
});
}
@@ -189,7 +190,7 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
title={s.enabled ? "Disable" : "Enable"}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
s.enabled
? "border-green-400 text-green-700 dark:text-green-400"
? "border-[var(--c-success)] text-[var(--c-success)]"
: "border-[var(--ink-faint)] text-[var(--ink-mute)]"
}`}
>
@@ -256,15 +257,6 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
</div>
</div>
)}
{calendarSuccess === s.id && (
<p className="text-xs text-green-600 dark:text-green-400 ml-2">
Event added to calendar.{" "}
<Link href="/calendar" className="underline">
View in calendar
</Link>
</p>
)}
</div>
))}
</div>
+134 -117
View File
@@ -2,9 +2,14 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ShareButton } from "@/components/share-button";
import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
addContainerImage,
deleteContainer,
@@ -32,9 +37,14 @@ export function ContainerDetail({ container, shareLinks }: Props) {
function handleDelete() {
startTransition(async () => {
await deleteContainer({ id: container.id });
router.push("/garden");
router.refresh();
try {
await deleteContainer({ id: container.id });
toast.success("Container deleted");
router.push("/garden");
router.refresh();
} catch {
toast.error("Failed to delete container");
}
});
}
@@ -53,6 +63,7 @@ export function ContainerDetail({ container, shareLinks }: Props) {
router.refresh();
} catch {
setGalleryError("Image upload failed.");
toast.error("Image upload failed");
} finally {
setUploadingImage(false);
e.target.value = "";
@@ -137,130 +148,136 @@ export function ContainerDetail({ container, shareLinks }: Props) {
)}
{/* 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)]"
}`}
>
{t}
</button>
))}
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
<TabsList variant="line">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
</TabsList>
<Separator />
{/* 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>
<TabsContent value="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>
<Badge
variant={
p.healthStatus === "healthy"
? "default"
: p.healthStatus === "sick"
? "destructive"
: "secondary"
}
className="ml-auto capitalize"
>
{p.healthStatus}
</Badge>
</div>
</a>
))}
</div>
)}
</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 && (
</TabsContent>
<TabsContent value="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="relative">
{uploadingImage && <Skeleton className="absolute inset-0 z-10 rounded-lg" />}
<div className="grid grid-cols-3 gap-2">
{container.images.map((url) => (
<div key={url} className="relative group">
<img
src={p.primaryImageUrl}
src={url}
alt=""
className="w-10 h-10 rounded-full object-cover shrink-0"
className="w-full aspect-square object-cover rounded-lg"
/>
)}
<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 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>
<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>
</div>
)}
<span className="text-xs text-[var(--ink-mute)]">
{container.images.length}/10 photos
</span>
{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>
)}
</TabsContent>
</Tabs>
</div>
);
}
@@ -3,6 +3,14 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Container } from "lucide-react";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { ContainerForm } from "./container-form";
import type { ContainerDto } from "../server/queries";
@@ -35,9 +43,15 @@ export function ContainerList({ containers }: Props) {
)}
{containers.length === 0 && !showNew && (
<p className="text-sm text-[var(--ink-mute)]">
No containers yet. Add one to start organising your plants.
</p>
<Empty className="border-none">
<EmptyHeader>
<EmptyMedia variant="icon">
<Container />
</EmptyMedia>
<EmptyTitle>No containers yet</EmptyTitle>
<EmptyDescription>Add a container to start organising your plants.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+151 -133
View File
@@ -3,12 +3,17 @@
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
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 { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CareHistoryList } from "./care-history-list";
import { CareLogForm } from "./care-log-form";
import { CareScheduleEditor } from "./care-schedule-editor";
@@ -41,10 +46,10 @@ function InfoRow({
);
}
function healthBadgeClass(status: string): string {
if (status === "healthy") return "badge-success";
if (status === "sick") return "badge-danger";
return "badge-warning";
function healthBadgeVariant(status: string): "default" | "destructive" | "secondary" {
if (status === "healthy") return "default";
if (status === "sick") return "destructive";
return "secondary";
}
export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLinks }: Props) {
@@ -57,9 +62,14 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
function handleDelete() {
startTransition(async () => {
await deletePlant({ id: plant.id });
router.push("/garden");
router.refresh();
try {
await deletePlant({ id: plant.id });
toast.success("Plant deleted");
router.push("/garden");
router.refresh();
} catch {
toast.error("Failed to delete plant");
}
});
}
@@ -78,6 +88,7 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
router.refresh();
} catch {
setGalleryError("Image upload failed.");
toast.error("Image upload failed");
} finally {
setUploadingImage(false);
e.target.value = "";
@@ -116,11 +127,13 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
<p className="text-sm text-[var(--ink-mute)] italic mt-0.5">{plant.scientificName}</p>
)}
<div className="flex flex-wrap gap-2 mt-1">
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
<Badge variant={healthBadgeVariant(plant.healthStatus)} className="capitalize">
{plant.healthStatus}
</span>
</Badge>
{plant.growthStage && (
<span className="text-xs badge badge-outline capitalize">{plant.growthStage}</span>
<Badge variant="outline" className="capitalize">
{plant.growthStage}
</Badge>
)}
</div>
</div>
@@ -160,135 +173,140 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
</div>
{/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{(["info", "gallery", "care"] 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)]"
}`}
>
{t}
</button>
))}
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-0">
<TabsList variant="line">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
<TabsTrigger value="care">Care</TabsTrigger>
</TabsList>
<Separator />
{/* Info */}
{tab === "info" && (
<div className="flex flex-col gap-2">
<InfoRow label="Category" value={plant.category} />
{plant.containerName && (
<InfoRow label="Container">
<Link href={`/garden/containers/${plant.containerId}`} className="underline">
{plant.containerName}
</Link>
</InfoRow>
)}
<InfoRow label="Acquired" value={plant.acquisitionDate} />
<InfoRow label="Sunlight" value={plant.sunlight} />
<InfoRow label="Watering" value={plant.wateringNotes} />
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
<InfoRow label="Notes" value={plant.notes} />
{plant.recentCareLogs.length > 0 && (
<div className="mt-2">
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
Recent care
</p>
<ul className="text-sm space-y-1">
{plant.recentCareLogs.map((log) => (
<li key={log.id} className="flex gap-2 flex-wrap">
<span className="capitalize">{log.careType}</span>
<span className="text-[var(--ink-mute)]">
{new Date(log.performedAt).toLocaleDateString()}
</span>
{log.notes && <span className="text-[var(--ink-mute)]"> {log.notes}</span>}
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Gallery */}
{tab === "gallery" && (
<div className="flex flex-col gap-4">
{plant.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="grid grid-cols-3 gap-2">
{plant.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 primary"
className={`text-lg leading-none ${url === plant.primaryImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === plant.primaryImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Primary
</span>
)}
</div>
))}
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{plant.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>
<TabsContent value="info">
<div className="flex flex-col gap-2">
<InfoRow label="Category" value={plant.category} />
{plant.containerName && (
<InfoRow label="Container">
<Link href={`/garden/containers/${plant.containerId}`} className="underline">
{plant.containerName}
</Link>
</InfoRow>
)}
<InfoRow label="Acquired" value={plant.acquisitionDate} />
<InfoRow label="Sunlight" value={plant.sunlight} />
<InfoRow label="Watering" value={plant.wateringNotes} />
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
<InfoRow label="Notes" value={plant.notes} />
{plant.recentCareLogs.length > 0 && (
<div className="mt-2">
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
Recent care
</p>
<ul className="text-sm space-y-1">
{plant.recentCareLogs.map((log) => (
<li key={log.id} className="flex gap-2 flex-wrap">
<span className="capitalize">{log.careType}</span>
<span className="text-[var(--ink-mute)]">
{new Date(log.performedAt).toLocaleDateString()}
</span>
{log.notes && <span className="text-[var(--ink-mute)]"> {log.notes}</span>}
</li>
))}
</ul>
</div>
)}
<span className="text-xs text-[var(--ink-mute)]">{plant.images.length}/10 photos</span>
</div>
</div>
)}
</TabsContent>
{/* Care */}
{tab === "care" && (
<div className="flex flex-col gap-6">
<CareScheduleEditor plantId={plant.id} schedules={careSchedules} calendars={calendars} />
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
Log care
</p>
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
<TabsContent value="gallery">
<div className="flex flex-col gap-4 relative">
{plant.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="relative">
{uploadingImage && <Skeleton className="absolute inset-0 z-10 rounded-lg" />}
<div className="grid grid-cols-3 gap-2">
{plant.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 primary"
className={`text-lg leading-none ${url === plant.primaryImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === plant.primaryImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Primary
</span>
)}
</div>
))}
</div>
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{plant.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)]">
{plant.images.length}/10 photos
</span>
</div>
</div>
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
History
</p>
<CareHistoryList logs={careLogs} />
</TabsContent>
<TabsContent value="care">
<div className="flex flex-col gap-6">
<CareScheduleEditor
plantId={plant.id}
schedules={careSchedules}
calendars={calendars}
/>
<Separator />
<div>
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
Log care
</p>
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
</div>
<Separator />
<div>
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
History
</p>
<CareHistoryList logs={careLogs} />
</div>
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+18 -4
View File
@@ -1,7 +1,15 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import type { PlantListItemDto } from "../server/queries";
import { Sprout } from "lucide-react";
type Props = {
plants: PlantListItemDto[];
@@ -18,12 +26,18 @@ function daysAgo(isoString: string): string {
export function PlantList({ plants }: Props) {
if (plants.length === 0) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>
<Empty className="border-none py-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<Sprout />
</EmptyMedia>
<EmptyTitle>No plants yet</EmptyTitle>
<EmptyDescription>Add your first plant to get started.</EmptyDescription>
</EmptyHeader>
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
Add your first plant
</Link>
</div>
</Empty>
);
}
@@ -96,7 +110,7 @@ export function PlantList({ plants }: Props) {
)}
</div>
{plant.hasOverdueCare && (
<p className="text-xs text-amber-600 mt-0.5">Care overdue</p>
<p className="text-xs text-[var(--warn)] mt-0.5">Care overdue</p>
)}
</div>
</div>
@@ -12,9 +12,8 @@ const CARE_ICONS: Record<string, string> = {
};
function urgencyLabel(days: number): { text: string; cls: string } {
if (days < 0)
return { text: `${Math.abs(days)}d overdue`, cls: "text-red-600 dark:text-red-400" };
if (days === 0) return { text: "due today", cls: "text-amber-600 dark:text-amber-400" };
if (days < 0) return { text: `${Math.abs(days)}d overdue`, cls: "text-[var(--bad)]" };
if (days === 0) return { text: "due today", cls: "text-[var(--warn)]" };
return { text: `in ${days}d`, cls: "text-[var(--ink-mute)]" };
}
@@ -108,7 +107,7 @@ export function GardenOverviewWidget({ stats }: { stats: GardenOverviewStats })
</div>
{stats.overdueCount > 0 && (
<div className="flex flex-col items-center">
<span className="text-2xl font-bold leading-none text-red-500">
<span className="text-2xl font-bold leading-none text-[var(--bad)]">
{stats.overdueCount}
</span>
<span className="text-xs text-[var(--ink-mute)] mt-0.5">overdue</span>
+6 -2
View File
@@ -4,6 +4,8 @@ import { db } from "@/lib/db";
import { registerItemToggleHook } from "../_core/registry";
import type { ModuleManifest, WidgetContext } from "../_core/module";
import {
canShareContainer,
canSharePlant,
loadContainerForShare,
loadPlantForShare,
type ContainerShareData,
@@ -45,7 +47,8 @@ const gardenManifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchPlants },
resolveUrl: (id) => `/garden/plants/${id}`,
loadForShare: (id) => loadPlantForShare(id),
canShareEntity: canSharePlant,
loadForShare: loadPlantForShare,
renderSharedView: ({ data }) => {
const d = data as PlantShareData;
return (
@@ -109,7 +112,8 @@ const gardenManifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchContainers },
resolveUrl: (id) => `/garden/containers/${id}`,
loadForShare: (id) => loadContainerForShare(id),
canShareEntity: canShareContainer,
loadForShare: loadContainerForShare,
renderSharedView: ({ data }) => {
const d = data as ContainerShareData;
return (
+32 -5
View File
@@ -1,5 +1,6 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { gardenContainers, gardenPlants } from "../schema";
export type ContainerShareData = {
@@ -27,7 +28,30 @@ export type PlantShareData = {
sunlight: string | null;
};
export async function loadPlantForShare(id: string): Promise<PlantShareData | null> {
export async function canSharePlant(id: string, ctx: ShareContext): Promise<boolean> {
const [plant] = await db
.select({ id: gardenPlants.id })
.from(gardenPlants)
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, ctx.householdId)))
.limit(1);
return !!plant;
}
export async function canShareContainer(id: string, ctx: ShareContext): Promise<boolean> {
const [container] = await db
.select({ id: gardenContainers.id })
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, ctx.householdId)))
.limit(1);
return !!container;
}
export async function loadPlantForShare(
id: string,
ctx: PublicShareContext,
): Promise<PlantShareData | null> {
const [plant] = await db
.select({
id: gardenPlants.id,
@@ -44,18 +68,21 @@ export async function loadPlantForShare(id: string): Promise<PlantShareData | nu
sunlight: gardenPlants.sunlight,
})
.from(gardenPlants)
.where(eq(gardenPlants.id, id))
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, ctx.householdId)))
.limit(1);
if (!plant) return null;
return plant;
}
export async function loadContainerForShare(id: string): Promise<ContainerShareData | null> {
export async function loadContainerForShare(
id: string,
ctx: PublicShareContext,
): Promise<ContainerShareData | null> {
const [container] = await db
.select()
.from(gardenContainers)
.where(eq(gardenContainers.id, id))
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, ctx.householdId)))
.limit(1);
if (!container) return null;
@@ -67,7 +94,7 @@ export async function loadContainerForShare(id: string): Promise<ContainerShareD
scientificName: gardenPlants.scientificName,
})
.from(gardenPlants)
.where(and(eq(gardenPlants.containerId, id)));
.where(and(eq(gardenPlants.containerId, id), eq(gardenPlants.householdId, ctx.householdId)));
return {
id: container.id,
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod";
import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries";
import { loadListForShare, type ListShareData } from "./server/share-queries";
import { canShareList, loadListForShare, type ListShareData } from "./server/share-queries";
import { ListSharedView } from "./components/shared-view";
import { ListWidget } from "./components/list-widget";
@@ -34,7 +34,8 @@ const manifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read", "write"] },
search: { search: searchLists },
resolveUrl: (id) => `/lists/${id}`,
loadForShare: (id) => loadListForShare(id),
canShareEntity: canShareList,
loadForShare: loadListForShare,
renderSharedView: ({ data, capabilities, token }) => (
<ListSharedView data={data as ListShareData} canWrite={capabilities.write} token={token} />
),
+17 -3
View File
@@ -1,5 +1,6 @@
import { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { listItems, lists } from "../schema";
export type ListShareItem = {
@@ -19,7 +20,20 @@ export type ListShareData = {
items: ListShareItem[];
};
export async function loadListForShare(id: string): Promise<ListShareData | null> {
export async function canShareList(id: string, ctx: ShareContext): Promise<boolean> {
const [list] = await db
.select({ id: lists.id })
.from(lists)
.where(and(eq(lists.id, id), eq(lists.householdId, ctx.householdId)))
.limit(1);
return !!list;
}
export async function loadListForShare(
id: string,
ctx: PublicShareContext,
): Promise<ListShareData | null> {
const [list] = await db
.select({
id: lists.id,
@@ -28,7 +42,7 @@ export async function loadListForShare(id: string): Promise<ListShareData | null
householdId: lists.householdId,
})
.from(lists)
.where(eq(lists.id, id))
.where(and(eq(lists.id, id), eq(lists.householdId, ctx.householdId)))
.limit(1);
if (!list) return null;
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod";
import { listWidgetNotes, searchNotes } from "./server/queries";
import { loadNoteForShare, type NoteShareData } from "./server/share-queries";
import { canShareNote, loadNoteForShare, type NoteShareData } from "./server/share-queries";
import { NoteSharedView } from "./components/shared-view";
const notesWidgetConfigSchema = z.object({
@@ -72,7 +72,8 @@ const manifest: ModuleManifest = {
reminder: { canRemind: true },
search: { search: searchNotes },
resolveUrl: (id) => `/notes/${id}`,
loadForShare: (id) => loadNoteForShare(id),
canShareEntity: canShareNote,
loadForShare: loadNoteForShare,
renderSharedView: ({ data }) => <NoteSharedView data={data as NoteShareData} />,
renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined;
+17 -3
View File
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { notes } from "../schema";
export type NoteShareData = {
@@ -10,7 +11,20 @@ export type NoteShareData = {
updatedAt: string;
};
export async function loadNoteForShare(id: string): Promise<NoteShareData | null> {
export async function canShareNote(id: string, ctx: ShareContext): Promise<boolean> {
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.id, id), eq(notes.householdId, ctx.householdId)))
.limit(1);
return !!note;
}
export async function loadNoteForShare(
id: string,
ctx: PublicShareContext,
): Promise<NoteShareData | null> {
const [note] = await db
.select({
id: notes.id,
@@ -20,7 +34,7 @@ export async function loadNoteForShare(id: string): Promise<NoteShareData | null
updatedAt: notes.updatedAt,
})
.from(notes)
.where(eq(notes.id, id))
.where(and(eq(notes.id, id), eq(notes.householdId, ctx.householdId)))
.limit(1);
if (!note) return null;