fix: tighten sharing and shadcn composition
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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