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
+5 -4
View File
@@ -11,6 +11,7 @@ import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab";
import { DashboardGreeting } from "@/components/dashboard-greeting";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { dashboards } from "@/modules/_core/schema";
@@ -113,10 +114,10 @@ export default async function DashboardPage({
<CardContent>
<Suspense
fallback={
<div className="animate-pulse space-y-2">
<div className="h-3 w-3/4 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
<div className="h-3 w-2/3 rounded bg-muted" />
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
}
>
+3 -1
View File
@@ -67,6 +67,7 @@
--radius-md: var(--r-md);
--radius-lg: var(--r-lg);
--radius-xl: var(--r-xl);
--color-ok-dark: var(--c-success);
}
/* ── Paper-and-ink primitives (shared across all palettes) ────────── */
@@ -98,7 +99,7 @@
--ok: #4f7a3f;
--warn: #c99a3f;
--bad: #b05246;
--c-success: var(--ok);
/* Density (regular by default; overridden via [data-density]) */
--row: 48px;
--pad: 14px;
@@ -342,6 +343,7 @@
--ok: #82b070;
--warn: #dcb46a;
--bad: #d8746a;
--c-success: var(--ok);
--background: var(--paper);
--foreground: var(--ink);
+2
View File
@@ -15,6 +15,7 @@ import { CommandPalette } from "@/components/command-palette";
import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -169,6 +170,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<CommandPalette />
<InstallPrompt />
<PwaRegister />
<AppToaster position="bottom-right" />
</QuickAddProvider>
</body>
</html>
+3 -1
View File
@@ -38,7 +38,9 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
return <ShareError message="This content type cannot be shared." />;
}
const data = await entityReg.loadForShare(resolved.entityId);
const data = await entityReg.loadForShare(resolved.entityId, {
householdId: resolved.householdId,
});
if (!data) {
recordFailure(rlKey);
return <ShareError />;
+6 -7
View File
@@ -9,6 +9,7 @@ import { PushOptIn } from "@/components/push-opt-in";
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
@@ -43,7 +44,7 @@ export default async function SettingsPage({
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
<ResponsiveSidebar active={section} />
<div className="space-y-4">
{section === "household" && <HouseholdSection household={household} userName={user.name} />}
{section === "household" && <HouseholdSection household={household} user={user} />}
{section === "sharing" && <SharingSection />}
{section === "notifications" && (
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
@@ -58,10 +59,10 @@ export default async function SettingsPage({
function HouseholdSection({
household,
userName,
user,
}: {
household: { id: string; name: string };
userName: string | null;
user: { name: string | null; image: string | null };
}) {
return (
<>
@@ -91,11 +92,9 @@ function HouseholdSection({
</CardHeader>
<CardContent>
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
{(userName ?? "?").trim()[0]?.toUpperCase()}
</span>
<AvatarFallbackWithName name={user.name ?? undefined} image={user.image} />
<div className="label">
<div className="t">{userName ?? "Anonymous"}</div>
<div className="t">{user.name ?? "Anonymous"}</div>
<div className="d">Signed in via Authentik</div>
</div>
</div>
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
export function AppToaster({ ...props }: ToasterProps) {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const sync = () => {
setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return (
<Sonner
theme={theme}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
export function AvatarFallbackWithName({
name,
initial,
image,
size = "default",
className,
fallbackStyle,
title,
}: {
name?: string;
initial?: string;
image?: string | null;
size?: "sm" | "default" | "lg";
className?: string;
fallbackStyle?: React.CSSProperties;
title?: string;
}) {
const computedInitial = initial ?? name?.trim()[0]?.toUpperCase() ?? "?";
const sizeClass =
size === "sm" ? "size-7 text-xs" : size === "lg" ? "size-10 text-base" : "size-8 text-sm";
return (
<span title={title}>
<Avatar className={cn(sizeClass, className)}>
{image ? (
<AvatarImage src={image} alt={name ?? ""} />
) : (
<AvatarFallback
style={fallbackStyle}
className={cn(!fallbackStyle && "bg-[var(--c-household)]")}
>
{computedInitial}
</AvatarFallback>
)}
</Avatar>
</span>
);
}
+3 -3
View File
@@ -139,7 +139,7 @@ export function DashboardEditor({
onClick={() => setPresetMenuOpen((o) => !o)}
disabled={isPending}
>
<LayoutGrid className="size-4 mr-1" />
<LayoutGrid data-icon="inline-start" />
Preset
</Button>
{presetMenuOpen && (
@@ -173,7 +173,7 @@ export function DashboardEditor({
)}
</div>
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
<RotateCcw className="size-4 mr-1" />
<RotateCcw data-icon="inline-start" />
Reset
</Button>
<Button
@@ -182,7 +182,7 @@ export function DashboardEditor({
onClick={() => setPickerOpen(true)}
disabled={isPending}
>
<Plus className="size-4 mr-1" />
<Plus data-icon="inline-start" />
Add widget
</Button>
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isPending}>
+23 -18
View File
@@ -13,6 +13,7 @@ import type { DashboardMeta } from "@/app/d/actions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
@@ -168,28 +169,32 @@ function DashboardKebab({
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<PenLine className="size-4 mr-2" />
Rename
</DropdownMenuItem>
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star className="size-4 mr-2" />
Set as default
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<PenLine />
Rename
</DropdownMenuItem>
)}
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star />
Set as default
</DropdownMenuItem>
)}
</DropdownMenuGroup>
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 className="size-4 mr-2" />
Delete
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
+3 -4
View File
@@ -2,6 +2,7 @@
import { useTransition } from "react";
import { setNotifChannel } from "@/app/settings/notify-actions";
import { Switch } from "@/components/ui/switch";
type Channel = "push" | "inapp" | "ntfy";
@@ -46,12 +47,10 @@ export function NotifyChannelToggles({
<span className="ml-2 text-xs text-muted-foreground">(NTFY_URL not configured)</span>
)}
</span>
<input
type="checkbox"
<Switch
checked={value}
disabled={isPending || disabled}
onChange={(e) => toggle(key, e.target.checked)}
className="size-4 cursor-pointer"
onCheckedChange={(checked) => toggle(key, checked)}
/>
</label>
))}
+1 -1
View File
@@ -101,7 +101,7 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
if (status === "subscribed") {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-green-600 dark:text-green-400">
<span className="text-sm text-[var(--c-success)] dark:text-[var(--c-success)]">
Push notifications enabled
</span>
<Button size="sm" variant="outline" onClick={sendTest} disabled={isPending}>
+7 -3
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react";
import { Link, Check, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -32,7 +33,9 @@ export function ShareButton({
setShareUrl(result.url);
setOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create share link");
const message = err instanceof Error ? err.message : "Failed to create share link";
setError(message);
toast.error(message);
}
});
}
@@ -41,6 +44,7 @@ export function ShareButton({
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
});
}
@@ -49,7 +53,7 @@ export function ShareButton({
<>
<div className="flex flex-col items-end gap-1">
<Button variant="outline" onClick={share} disabled={isPending}>
<Link />
<Link data-icon="inline-start" />
Share
</Button>
{error && <p className="text-xs text-red-500">{error}</p>}
@@ -67,7 +71,7 @@ export function ShareButton({
<div className="flex gap-2">
<Input readOnly value={shareUrl ?? ""} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={copyUrl} aria-label="Copy link">
{copied ? <Check className="text-green-600" /> : <Copy />}
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
</Button>
</div>
</DialogContent>
+12 -17
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { eq } from "drizzle-orm";
import { cn } from "@/lib/utils";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { getRegistry } from "@/modules/_core/registry";
@@ -7,6 +8,7 @@ import { householdMembers, households, users } from "@/modules/_core/schema";
import { BrandMark } from "@/components/brand-mark";
import { NavIcon } from "@/components/nav-icon";
import { NavLink } from "@/components/nav-link";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
type Variant = "side" | "top";
@@ -102,23 +104,16 @@ function HouseholdPill({
return (
<div className="household-pill" title={info.household.name}>
<div style={{ display: "flex" }}>
{visible.map((m, i) => {
const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
return (
<span
key={m.id}
className="avatar avatar-sm"
style={{
background: avatarColor(m.id),
marginLeft: i > 0 ? -6 : 0,
boxShadow: "0 0 0 1.5px var(--card)",
}}
title={m.name ?? m.email ?? undefined}
>
{initial}
</span>
);
})}
{visible.map((m, i) => (
<AvatarFallbackWithName
key={m.id}
name={m.name ?? m.email ?? undefined}
image={m.image}
className={cn(i > 0 && "-ml-1.5", "shadow-[0_0_0_1.5px_var(--card)]")}
fallbackStyle={{ background: avatarColor(m.id) }}
title={m.name ?? m.email ?? undefined}
/>
))}
</div>
<div className="household-text" style={{ minWidth: 0 }}>
<div
+63 -33
View File
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
@@ -41,6 +42,11 @@ interface Props {
signedIn?: boolean;
}
const PALETTE_ITEMS = PALETTES.map((p) => ({ label: p.label, value: p.id }));
const FONT_PAIR_ITEMS = FONT_PAIRS.map((f) => ({ label: f.label, value: f.id }));
const DASH_LAYOUT_ITEMS = DASH_LAYOUTS.map((d) => ({ label: d.label, value: d.id }));
const NAV_STYLE_ITEMS = NAV_STYLES.map((n) => ({ label: n.label, value: n.id }));
export function ThemePicker({
initialPalette = "clay",
initialMode = "system",
@@ -68,7 +74,11 @@ export function ThemePicker({
<div className="space-y-5">
<div className="space-y-1.5">
<Label>Theme</Label>
<Select value={t.palette} onValueChange={(v) => t.setPalette(v as Palette)}>
<Select
items={PALETTE_ITEMS}
value={t.palette}
onValueChange={(v) => t.setPalette(v as Palette)}
>
<SelectTrigger className="w-56">
<div className="flex items-center gap-2 min-w-0">
<span
@@ -84,20 +94,22 @@ export function ThemePicker({
</div>
</SelectTrigger>
<SelectContent>
{PALETTES.map((p) => (
<SelectItem key={p.id} value={p.id}>
<div className="flex items-center justify-between gap-6 w-40">
<span>{p.label}</span>
<span
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
style={{
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
borderColor: "var(--hair-2)",
}}
/>
</div>
</SelectItem>
))}
<SelectGroup>
{PALETTES.map((p) => (
<SelectItem key={p.id} value={p.id}>
<div className="flex items-center justify-between gap-6 w-40">
<span>{p.label}</span>
<span
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
style={{
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
borderColor: "var(--hair-2)",
}}
/>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -121,16 +133,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Type pairing</Label>
<Select value={t.fontPair} onValueChange={(v) => t.setFontPair(v as FontPair)}>
<Select
items={FONT_PAIR_ITEMS}
value={t.fontPair}
onValueChange={(v) => t.setFontPair(v as FontPair)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_PAIRS.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.label}
</SelectItem>
))}
<SelectGroup>
{FONT_PAIRS.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -154,16 +172,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Dashboard layout</Label>
<Select value={t.dashLayout} onValueChange={(v) => t.setDashLayout(v as DashLayout)}>
<Select
items={DASH_LAYOUT_ITEMS}
value={t.dashLayout}
onValueChange={(v) => t.setDashLayout(v as DashLayout)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DASH_LAYOUTS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
<SelectGroup>
{DASH_LAYOUTS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -187,16 +211,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Navigation</Label>
<Select value={t.navStyle} onValueChange={(v) => t.setNavStyle(v as NavStyle)}>
<Select
items={NAV_STYLE_ITEMS}
value={t.navStyle}
onValueChange={(v) => t.setNavStyle(v as NavStyle)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NAV_STYLES.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.label}
</SelectItem>
))}
<SelectGroup>
{NAV_STYLES.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
+6 -16
View File
@@ -6,6 +6,7 @@ import { NotificationBell } from "@/components/notification-bell";
import { TopbarSearch } from "@/components/topbar-search";
import { TopbarTitle } from "@/components/topbar-title";
import { TopbarNewButton } from "@/components/topbar-new-button";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
async function getNotifications(userId: string) {
const rows = await db
@@ -35,7 +36,6 @@ export async function Topbar() {
? await getNotifications(userId)
: { rows: [], unread: 0 };
const userRow = userId ? await getUserAvatar(userId) : null;
const initial = (userRow?.name ?? userRow?.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
return (
<div className="topbar">
@@ -56,22 +56,12 @@ export async function Topbar() {
)}
<TopbarNewButton />
{userId && (
<span
className="avatar"
style={{ width: 28, height: 28, fontSize: 12, background: "var(--c-household)" }}
<AvatarFallbackWithName
name={userRow?.name ?? userRow?.email ?? undefined}
image={userRow?.image}
size="sm"
title={userRow?.name ?? userRow?.email ?? undefined}
>
{userRow?.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={userRow.image}
alt=""
style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }}
/>
) : (
initial
)}
</span>
/>
)}
</div>
</div>
+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;