fix: tighten sharing and shadcn composition
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user