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
View File
@@ -6,6 +6,11 @@ This file is the canonical brief. Read it at the start of every session before m
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory. Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
## Regular agent skills
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to `components.json` / `src/components/ui`.
--- ---
## Goals ## Goals
+5
View File
@@ -6,6 +6,11 @@ This file is the canonical brief. Read it at the start of every session before m
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory. Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
## Regular agent skills
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to `components.json` / `src/components/ui`.
--- ---
## Goals ## Goals
@@ -0,0 +1,141 @@
# React and shadcn Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the security-sensitive share-link authorization gap and the highest-confidence shadcn Base UI composition issues from the audit.
**Architecture:** Add registry-level share authorization callbacks and require them in the generic share service before token creation and shared data loading. Update current modules to provide household/user-scoped authorization and share loaders, then align selected UI call sites with shadcn Base API.
**Tech Stack:** Next.js 15 App Router, TypeScript, Drizzle, shadcn/ui Base UI, Tailwind v4, Node test runner via `tsx --test`.
---
### Task 1: Share Authorization Helper
**Files:**
- Create: `tests/unit/share-authorization.test.ts`
- Create: `src/modules/_core/share-authorization.ts`
- Modify: `src/modules/_core/module.ts`
- [ ] **Step 1: Write the failing test**
Create tests for missing callbacks, denied callbacks, and allowed callbacks.
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: failure because `share-authorization.ts` does not exist.
- [ ] **Step 3: Implement the helper and registry types**
Add `ShareContext`, `ensureEntityShareAuthorized`, `EntityTypeRegistration.canShareEntity`, and scoped `loadForShare` context typing.
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: all tests pass.
### Task 2: Wire Authorization Into Share Service
**Files:**
- Modify: `src/modules/_core/share.ts`
- Modify: `src/app/s/[token]/page.tsx`
- [ ] **Step 1: Call `ensureEntityShareAuthorized` in `createShareLink`**
Require the active user and household to be authorized before token insertion.
- [ ] **Step 2: Pass household context into public share loading**
Call `entityReg.loadForShare(resolved.entityId, { householdId: resolved.householdId })`.
- [ ] **Step 3: Run typecheck**
Run: `pnpm typecheck`
Expected: type errors until module adapters are updated.
### Task 3: Add Module Share Adapters
**Files:**
- Modify: `src/modules/calendar/manifest.tsx`
- Modify: `src/modules/calendar/server/share-queries.ts`
- Modify: `src/modules/lists/manifest.tsx`
- Modify: `src/modules/lists/server/share-queries.ts`
- Modify: `src/modules/notes/manifest.tsx`
- Modify: `src/modules/notes/server/share-queries.ts`
- Modify: `src/modules/garden/manifest.tsx`
- Modify: `src/modules/garden/server/share-queries.ts`
- [ ] **Step 1: Implement `canShareEntity` for every shareable entity**
Use household scoping and existing private-calendar rules.
- [ ] **Step 2: Scope `loadForShare` queries by token household**
Require the public token household to match the entity household.
- [ ] **Step 3: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
### Task 4: Fix Base UI Select Composition
**Files:**
- Modify: `src/modules/calendar/components/calendar-shell.tsx`
- Modify: `src/components/theme-picker.tsx`
- [ ] **Step 1: Add `items` arrays and `SelectGroup` wrappers**
Keep existing labels and values; do not redesign layout.
- [ ] **Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
### Task 5: Targeted shadcn Menu and Icon Cleanup
**Files:**
- Modify: `src/components/dashboard-switcher.tsx`
- Modify: `src/components/dashboard-editor.tsx`
- Modify: `src/components/share-button.tsx`
- [ ] **Step 1: Wrap dropdown items in `DropdownMenuGroup`**
Preserve menu behavior.
- [ ] **Step 2: Replace icon sizing inside shadcn buttons/menu items**
Use `data-icon` where icons sit in `Button`; remove explicit `size-4 mr-*` from dropdown item icons.
- [ ] **Step 3: Run lint**
Run: `pnpm lint`
Expected: no new errors; existing warnings may remain.
### Task 6: Final Verification
**Files:**
- No direct edits.
- [ ] **Step 1: Run targeted test**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: pass.
- [ ] **Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
- [ ] **Step 3: Run lint**
Run: `pnpm lint`
Expected: exit 0; warnings only if pre-existing.
@@ -0,0 +1,35 @@
# React and shadcn Fixes Design
## Goal
Fix the audit findings that have security or correctness impact, and make a small targeted pass on shadcn Base UI composition without turning this into a broad visual rewrite.
## Approaches Considered
1. **Security-first, targeted UI cleanup.** Add a registry authorization hook for share links, implement it for current modules, fix Base `Select` usage, and clean up the compact dropdown/button issues found in the audit.
2. **Full shadcn migration sweep.** Replace all `.btn`, raw forms, raw colors, and hand-rolled overlays in one pass. This would touch many dirty files and blur behavior changes with style churn.
3. **Security only.** Fix share authorization and defer UI. This leaves known Base UI composition drift in place.
Chosen approach: **Option 1**. It fixes the exploitable path, handles the highest-confidence shadcn correctness issue, and leaves broad styling convergence to the existing shadcn-tier task docs.
## Architecture
Share authorization belongs in the entity registry, not in `_core` switch statements. Each shareable entity registration will expose `canShareEntity(id, ctx)` and the generic `createShareLink` action will require it to return true before inserting a token.
Public share rendering will also pass the token household into `loadForShare(id, ctx)` so loaders can scope database reads. This prevents a bad token row from loading an unrelated entity by id alone.
## UI Composition
The project uses shadcn `base-nova`, so `Select` roots need an `items` prop and `SelectItem` children should be wrapped in `SelectGroup`. The calendar and theme picker selects will be updated to that shape.
Compact menu/button drift will be cleaned where it does not require redesign: dropdown items should sit in `DropdownMenuGroup`, menu icons should rely on component icon sizing, and button icons should use `data-icon`.
## Testing
Add a small Node test around the new share authorization helper first. The test will prove that missing or false registry authorization rejects share creation and that approved entities pass through. Typecheck and lint will cover module hook signatures and Base UI prop usage.
## Out of Scope
- Full replacement of `.btn` and raw garden forms.
- Reworking command palette and quick-add sheet into shadcn dialogs.
- Measuring bundle deltas or adding dynamic imports for global overlays.
+104
View File
@@ -0,0 +1,104 @@
# Tier 1: Garden badges → Badge, topbar avatar → Avatar, + fallback, share-green → semantic token
## Goal
Replace ad-hoc badge and avatar styling across the app with shadcn/ui component primitives. Standardize color tokens for green success/warning indicators. Create a reusable avatar fallback pattern.
## Affected files (6)
### Task 1a: Garden health badges → Badge variant
**File:** `src/modules/garden/components/plant-detail.tsx`
- **Current state (line ~44-48):** `healthBadgeClass(status)` returns hand-crafted class names (`badge-success`, `badge-danger`, `badge-warning`) not from shadcn.
- **Line 119-123:** Badge rendered as a span with those classes.
- **Change:** Replace with `<Badge>` component:
- "healthy" → `variant="default"` (primary color)
- "sick" → `variant="destructive"`
- "sick/other" → `variant="secondary"`
- Delete `healthBadgeClass()` function. Remove the span and replace with Badge import + usage.
### Task 1b: Topbar avatar → `<Avatar>`
**File:** `src/components/topbar.tsx`
- **Current state (line ~60-75):** Manual `<span className="avatar">` with inline width/height/background styles, plus conditional img tag or initial text fallback.
- **Change:** Replace with shadcn `<Avatar>`:
```tsx
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
<Avatar style={{ width: 28, height: 28 }}>
{userRow?.image ? (
<AvatarImage src={userRow.image} alt="" />
) : (
<AvatarFallback>{initial}</AvatarFallback>
)}
</Avatar>;
```
- Remove the `getUserAvatar` function — already fetches name/email/image. Just pass the image directly.
### Task 1c: HouseholdPill avatars → Avatar with fallback
**File:** `src/components/sidebar.tsx`
- **Current state (line ~106-120):** Manual span-based avatar circles for household members. Uses `avatarColor()` hash function.
- **Change:** Replace inner spans with `<Avatar>`:
```tsx
<Avatar
style={{
width: 32,
height: 32,
marginLeft: i > 0 ? -6 : 0,
boxShadow: "0 0 0 1.5px var(--card)",
}}
>
{m.image ? (
<AvatarImage src={m.image} alt={m.name ?? m.email ?? ""} />
) : (
<AvatarFallback style={{ background: avatarColor(m.id) }}>{initial}</AvatarFallback>
)}
</Avatar>
```
- The `avatarColor()` function stays as-is — it produces the color variable for the fallback background.
### Task 1d: Create a generic Avatar fallback wrapper (reusable component)
**File:** `src/components/avatar-fallback.tsx` (new)
- **Purpose:** A thin wrapper that takes `(name|initial, image?)` and renders the right Avatar/AvatarImage/Fallback pattern with proper fallback initials.
- **Export:** `AvatarFallbackWithName` — accepts `{ name?: string; initial?: string; image?: string | null; size?: "sm" | "default" | "lg" }`
- Used by both topbar and sidebar avatars, removing code duplication.
### Task 1e: share-green → semantic token
**Files affected:**
1. `src/components/push-opt-in.tsx` line ~104: `text-green-600 dark:text-green-400` → `text-[var(--c-success)] dark:text-[var(--c-success)]`
2. `src/modules/garden/components/care-schedule-editor.tsx` line ~185: green borders for "Active" button → use a semantic border class like `border-[var(--c-success)] text-[var(--c-success)]`
3. `src/modules/garden/components/plant-widget.tsx` urgency colors: ensure overdue/due-today colors use semantic CSS vars rather than hardcoded greens/red/amber
**Changes:**
- In `src/lib/themes.ts` or the global CSS root, add: `--c-success: #16a34a; dark: --c-success: #4ade80;` (or pick from existing palette)
- Replace all `text-green-600`/`dark:text-green-400` → `text-[var(--c-success)] dark:text-[var(--c-success)]`
- Similarly for border-green-400 → `border-[var(--c-success)]`
## Acceptance criteria
1. No hardcoded badge-success/badge-danger/badge-warning class names remain in plant-detail.tsx
2. Topbar avatar renders via shadcn Avatar components (verified by DOM inspection)
3. HouseholdPill avatars use shadcn Avatar components
4. AvatarFallbackWithName component exists and is imported by both topbar.tsx and sidebar.tsx
5. All green-600/green-500 references replaced with --c-success semantic variable
6. Zero TypeScript errors, zero lint errors
## Steps
1. Edit plant-detail.tsx: replace healthBadgeClass usage with <Badge> component
2. Create src/components/avatar-fallback.tsx
3. Edit topbar.tsx: use Avatar from shadcn + AvatarFallbackWithName
4. Edit sidebar.tsx: update HouseholdPill to use Avatar components
5. Search for remaining green-600/green-500/border-green references and replace with --c-success
6. Run `pnpm lint` and `pnpm typecheck` to verify no errors
+183
View File
@@ -0,0 +1,183 @@
# Tier 2: Skeleton loading states, settings Tabs navigation, toast notifications via sonner, form FieldGroup audit, manual separators → <Separator>
## Goal
Standardize loading UX, navigation, and toast patterns across the app using shadcn components. Audit form field consistency. Replace hand-crafted separator borders with the shadcn Separator component.
---
### Task 2a: Skeleton loading states
**Files affected:**
1. `src/app/d/[slug]/page.tsx` line ~120 — inline `<Suspense fallback>` with animate-pulse divs (3 lines). Convert to `<Skeleton>` components.
2. `src/modules/garden/components/plant-detail.tsx` line 55 — `uploadingImage` state during image upload, no visual feedback other than button text. Add a skeleton overlay or shimmer on the gallery card area.
3. `src/modules/garden/components/container-detail.tsx` line 30 — same pattern: `uploadingImage` state without skeleton.
4. Any garden page with loading states that use animate-pulse instead of Skeleton.
**Changes:**
1. **d/[slug]/page.tsx:** Replace inline fallback divs:
```tsx
<Suspense fallback={
<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>
}>
```
2. **plant-detail.tsx / container-detail.tsx:** When `uploadingImage` is true, wrap the image area with a Skeleton overlay (absolute positioned, full-width). Or add a shimmer effect using the existing skeleton component.
---
### Task 2b: Settings Tabs navigation → `<Tabs>`
**File:** `src/app/settings/page.tsx` + `src/components/settings-section.tsx`
- **Current state:** Uses a sidebar nav (vertical list with URL hash sync). This is fine for desktop but doesn't use `<Tabs>`.
- **What needs to change:** The **garden detail pages** that have manual tab buttons need to use shadcn Tabs, NOT the settings page. The tier says "settings Tabs navigation" — checking the context: plant-detail.tsx line 164 and container-detail.tsx line 139 both have hand-crafted tab buttons with border-bottom active state. These should become `<Tabs>` + `<TabsList>` + `<TabsTrigger>` + `<TabsContent>`.
**Changes:**
1. **plant-detail.tsx (lines 162-180):** Replace manual tab buttons:
```tsx
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-4">
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
<TabsTrigger value="care">Care</TabsTrigger>
</TabsList>
<TabsContent value="info">{/* existing info section */}</TabsContent>
<TabsContent value="gallery">{/* existing gallery section */}</TabsContent>
<TabsContent value="care">{/* existing care section */}</TabsContent>
</Tabs>;
```
2. **container-detail.tsx (lines 138-155):** Same pattern — info/gallery tabs:
```tsx
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-4">
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
</TabsList>
<TabsContent value="info">{/* ... */}</TabsContent>
<TabsContent value="gallery">{/* ... */}</TabsContent>
</Tabs>
```
---
### Task 2c: Toast notifications via sonner
**Files affected:** Need to find all manual error/success messages and convert to toast calls.
1. **`src/app/layout.tsx`:** Add `<Toaster />` component to the app layout root so toasts are globally available.
2. **Places that need toast conversion (search for inline `setError` / success messages):**
- `src/modules/garden/components/plant-detail.tsx` — delete confirmation success/error → `toast.success()` / `toast.error()`
- `src/modules/garden/components/container-detail.tsx` — same pattern
- `src/modules/garden/components/care-schedule-editor.tsx` line 192 (`calendarSuccess`) and error states → toast
- `src/components/share-button.tsx` — "Share link created" dialog could be a toast instead (or keep as dialog but add toast on copy)
**Changes:**
```tsx
// In any component that needs toasts:
import { toast } from "sonner";
// On success:
toast.success("Event added to calendar");
// On error:
toast.error("Failed to create share link");
// In layout.tsx (client component or client wrapper):
import { Toaster } from "@/components/ui/sonner";
return <Toaster position="bottom-right" />;
```
**Acceptance for 2c:**
- `<Toaster>` is present in the app shell layout
- All delete confirmations show toasts on success/error
- The share dialog can remain as-is (it's a distinct UX), but copy action adds toast confirmation
---
### Task 2d: Form FieldGroup audit
**Scope:** Audit all form field components across the app. Check for consistency in label-input-error patterns.
**Files to audit:**
- `src/modules/garden/components/container-form.tsx` — garden container form fields
- `src/modules/garden/components/plant-form.tsx` — garden plant form fields
- `src/app/settings/household/*` — household edit forms
- Any other server/action-driven forms
**Audit checklist:**
1. Do all form inputs have `<Label>` from shadcn? (Not just plain text labels)
2. Are error messages rendered consistently below each field?
3. Are `Input` components from shadcn used everywhere? (No native `<input>` tags with manual styles)
4. Is there a FieldGroup wrapper pattern being used, or is it ad-hoc div nesting?
**Deliverable:** A list of inconsistencies found and fixes applied. If no major issues, document the findings. Do not introduce a FieldGroup abstraction unless one already exists in the codebase.
---
### Task 2e: Manual separators → `<Separator>`
**Files affected:**
1. `src/modules/garden/components/plant-detail.tsx` — border-bottom on tabs bar (`border-b border-[var(--ink-faint)]`) → use `<Separator>` instead
2. `src/modules/garden/components/container-detail.tsx` — same pattern for info/gallery tab separator
3. Any other manual `border-b`, `border-t`, or `border-l` borders used purely as visual separators (not structural layout borders)
**Changes:**
```tsx
import { Separator } from "@/components/ui/separator"
// Replace:
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{/* tab buttons */}
</div>
// With:
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
</TabsList>
<Separator className="my-2" />
// For the garden tab bar:
{tab === "gallery" && (
<Separator className="my-4" orientation="horizontal" />
)}
```
**Note:** The settings sidebar uses a `.nav-divider` class which is handled by CSS — leave that alone. Only convert inline border divs to Separator components.
---
## Acceptance criteria
1. All loading states use `<Skeleton>` (no animate-pulse patterns outside skeleton.tsx)
2. Garden detail page tabs use shadcn Tabs/TabsList/TabsTrigger/TabsContent
3. Toaster component rendered in app layout, delete/cancel actions show toasts
4. Form fields audit complete with findings documented or fixed
5. Manual border separators replaced with `<Separator>` where appropriate
## Steps
1. Add Skeleton imports and replace inline animation patterns (2a)
2. Replace garden detail page tabs with shadcn Tabs (2b)
3. Add Toaster to layout, add toast() calls to action handlers (2c)
4. Audit form fields for consistency (2d)
5. Replace border separators with Separator component (2e)
6. Run `pnpm lint` and `pnpm typecheck`
+143
View File
@@ -0,0 +1,143 @@
# Tier 3: Garden empty-state component, toggle-group for notify channels
## Goal
Create a reusable EmptyState component for garden (and potentially other modules), and replace manual checkbox patterns with shadcn ToggleGroup for notification channel selection.
---
### Task 3a: Garden empty-state component
**New file:** `src/modules/garden/components/empty-state.tsx`
**Purpose:** A shared empty-state component for the garden module — replacing ad-hoc "No plants yet." / "No containers yet." paragraphs scattered across garden list/detail pages.
**Design (base on shadcn's `<EmptyState>` from `src/components/ui/empty.tsx`):**
The empty component already exists at `src/components/ui/empty.tsx` — it provides a generic EmptyState wrapper with icon + title + description slots. We should **use it directly**, not create a garden-specific one, unless the garden needs garden-themed defaults.
**Garden-specific variants needed:**
- "No plants yet" — icon: leaf/plant, title: "No plants yet", description: "Add your first plant to get started."
- "No containers yet" — icon: box/pot, title: "No containers yet", description: "Add a container to group your plants."
**Files to update:**
1. `src/modules/garden/components/plant-list.tsx` line 22: `<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>``<EmptyState ... />`
2. `src/modules/garden/components/container-list.tsx` line 39: `"No containers yet. Add one to start organising your plants."``<EmptyState ... />`
3. `src/modules/garden/components/container-detail.tsx` line 169: `<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>` → inline EmptyState or keep as brief fallback (it's a sub-section, not a full page)
**Import:**
```tsx
import { EmptyState } from "@/components/ui/empty"
import { Sprout, Container } from "lucide-react"
// In plant-list:
<EmptyState
icon={Sprout}
title="No plants yet"
description="Add your first plant to get started."
/>
// In container-list:
<EmptyState
icon={Container}
title="No containers yet"
description="Add a container to start organising your plants."
/>
```
**Acceptance for 3a:**
- EmptyState imported from `@/components/ui/empty` in all garden list pages
- Zero ad-hoc empty text paragraphs remain in garden component files (check plant-list, container-list, and any other garden page)
- Garden empty states are visually consistent (same icon size, title weight, description color)
---
### Task 3b: Toggle-group for notify channels
**File:** `src/components/notify-channel-toggles.tsx`
**Current state:** Each channel is a native `<input type="checkbox">` wrapped in a label with custom styling. No grouping or visual cohesion.
**Change to ToggleGroup + ToggleGroupItem:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
export function NotifyChannelToggles({
push, inapp, ntfy, ntfyConfigured,
}: { ... }) {
const channels: { key: Channel; value: boolean; label: string; disabled?: boolean }[] = [
{ key: "push", value: push, label: "Web push" },
{ key: "inapp", value: inapp, label: "In-app inbox" },
{ key: "ntfy", value: ntfy, label: "ntfy", disabled: !ntfyConfigured },
];
const enabledKeys = channels.filter(c => c.value && !c.disabled).map(c => c.key);
return (
<ToggleGroup type="multiple" defaultValue={enabledKeys} onValueChange={(v) => handleToggle(v)}>
{channels.map(({ key, value, label, disabled }) => (
<ToggleGroupItem
key={key}
value={key}
disabled={disabled || !("PushManager" in window) && key === "push"}
aria-label={label}
>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
```
**Important:** The ToggleGroup needs to integrate with the existing server action flow (`setNotifChannel`). The `onValueChange` handler should:
1. Detect which channels were added/removed from the selection
2. Call `setNotifChannel(channel, enabled)` for each change
3. Still trigger an optimistic UI update
**Alternative approach** (safer — use individual ToggleGroupItems as independent switches): Since these are truly independent toggles (enabling push doesn't require disabling inapp), we keep the current per-channel rendering but swap `<input type="checkbox">` to shadcn's `<Switch>` component from `@/components/ui/switch`:
```tsx
import { Switch } from "@/components/ui/switch";
// Each channel:
<label className="flex items-center justify-between gap-4">
<span className="text-sm">{label}</span>
<Switch
checked={value}
disabled={disabled || isPending}
onCheckedChange={(v) => toggle(key, v)}
/>
</label>;
```
**Decision:** The `<Switch>` approach is more appropriate here. ToggleGroup is for mutually-exclusive selection; Switch is for independent on/off toggles — which matches the notification channels use case perfectly. **Use `Switch` from shadcn, not ToggleGroup.**
**Acceptance for 3b:**
- Each channel uses shadcn `<Switch>` component (not native checkbox)
- Visual appearance matches the app's design system
- Server action integration works correctly (toggle enables/disables on the server)
- Disabled state handled properly for ntfy when unconfigured
---
## Acceptance criteria
1. EmptyState component from `@/components/ui/empty` used in all garden list pages
2. All ad-hoc "No plants yet." / "No containers yet." text replaced with EmptyState or kept as contextual inline text where appropriate
3. Notification channels use shadcn `<Switch>` components
4. Visual consistency across garden empty states
## Steps
1. Use existing `src/components/ui/empty.tsx` EmptyState in plant-list.tsx and container-list.tsx
2. Replace Switch component imports in notify-channel-toggles.tsx
3. Verify server action integration for toggles
4. Run `pnpm lint` and `pnpm typecheck`
+114
View File
@@ -0,0 +1,114 @@
# 76 — Shadcn component implementation (Tiers 13)
## Goal
Implement the three shadcn component adoption tiers that replace ad-hoc UI patterns across the app with shadcn/ui primitives. This is the **execution** phase — components are already installed (see `2026-06-13-shadcn-component-install.md`).
| Tier | Name | Tasks | Files touched |
| ---- | ------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
| 1 | Garden badges → Badge, topbar avatar → Avatar, + fallback, semantic token | 5 sub-tasks | topbar.tsx, sidebar.tsx, plant-detail.tsx, push-opt-in.tsx, care-schedule-editor.tsx, plant-widget.tsx |
| 2 | Skeleton loading, Tabs nav, Sonner toasts, FieldGroup audit, Separator | 5 sub-tasks | d/[slug]/page.tsx, plant-detail.tsx, container-detail.tsx, layout.tsx, notify-channel-toggles.tsx |
| 3 | Garden empty-state, Switch for notify channels | 2 sub-tasks | plant-list.tsx, container-list.tsx, notify-channel-toggles.tsx |
## Depends on
- `2026-06-13-shadcn-component-install.md` — components are already in `src/components/ui/`
- The shadcn migration map at `2026-06-13-shadcn-migration.md`
## Scope by tier
### Tier 1 (detailed brief: `docs/tasks/1a-badges-avatars.md`)
**Task 1a — Garden health badges → `<Badge>`**
- File: `src/modules/garden/components/plant-detail.tsx` lines 44-48, 119-123
- Replace hand-crafted `badge-success/danger/warning` classes with shadcn `<Badge variant="default|destructive|secondary">`
- Delete the `healthBadgeClass()` helper function
**Task 1b — Topbar avatar → `<Avatar>`**
- File: `src/components/topbar.tsx` lines 60-75
- Replace manual `<span className="avatar">` with `<Avatar><AvatarImage/><AvatarFallback/></Avatar>`
**Task 1c — HouseholdPill avatars → `<Avatar>`**
- File: `src/components/sidebar.tsx` lines 106-120
- Replace inner span elements with Avatar components (keep avatarColor hash for fallback background)
**Task 1d — Generic AvatarFallbackWithName component**
- New file: `src/components/avatar-fallback.tsx`
- Thin wrapper that handles the Image/Fallback pattern with name/initial logic
- Imported by both topbar.tsx and sidebar.tsx to remove duplication
**Task 1e — share-green → semantic token**
- Files: push-opt-in.tsx (line ~104), care-schedule-editor.tsx (line ~185), plant-widget.tsx urgencyLabel, plus any other green-600/green-500 in garden components
- Add `--c-success` CSS variable to the global theme
- Replace all hardcoded green-600 references with `var(--c-success)`
### Tier 2 (detailed brief: `docs/tasks/1b-skeleton-tabs-toasts.md`)
**Task 2a — Skeleton loading states**
- d/[slug]/page.tsx inline animate-pulse → `<Skeleton>` components
- plant-detail.tsx & container-detail.tsx uploadingImage → skeleton overlay
- Any other animate-pulse patterns outside skeleton.tsx itself
**Task 2b — Settings/garden Tabs navigation → `<Tabs>`**
- File: `src/modules/garden/components/plant-detail.tsx` lines 162-180
- File: `src/modules/garden/components/container-detail.tsx` lines 138-155
- Replace manual button-based tabs with shadcn Tabs/TabsList/TabsTrigger/TabsContent
**Task 2c — Toast notifications via sonner**
- Add `<Toaster>` to app layout (`src/app/layout.tsx`)
- Convert delete confirmations, calendar schedule success/error, and share-link actions to `toast.success()`/`toast.error()` calls
**Task 2d — Form FieldGroup audit**
- Audit: container-form.tsx, plant-form.tsx, household edit forms
- Check consistency of Label + Input + error pattern
- Fix any non-shadcn form fields found
**Task 2e — Manual separators → `<Separator>`**
- Remove border-bottom on garden detail tab bars, replace with shadcn Separator (usually alongside the Tabs component)
### Tier 3 (detailed brief: `docs/tasks/1c-emptystate-switch.md`)
**Task 3a — Garden empty-state component**
- File: `src/modules/garden/components/plant-list.tsx` line 22 → use EmptyState from ui/empty.tsx
- File: `src/modules/garden/components/container-list.tsx` line 39 → use EmptyState
- Use appropriate icons (Sprout for plants, Container for containers)
**Task 3b — Notify channel toggles → `<Switch>`**
- File: `src/components/notify-channel-toggles.tsx`
- Replace native `<input type="checkbox">` with shadcn `<Switch>` component
- Keep server action integration (setNotifChannel)
- Disabled state for ntfy when unconfigured
## Acceptance criteria (all tiers)
1. Zero `badge-success`, `badge-danger`, `badge-warning` class names remain in garden code
2. All avatars rendered via shadcn Avatar components (verified by DOM/JSX inspection)
3. All loading states use `<Skeleton>` — no animate-pulse patterns outside skeleton.tsx
4. Garden detail pages use shadcn Tabs component (not manual buttons)
5. Toaster rendered in app layout, delete/cancel actions show toasts
6. EmptyState used in all garden list pages (no ad-hoc empty text paragraphs)
7. Notification channels use shadcn Switch components
8. `pnpm lint` passes with zero errors/warnings
9. `pnpm typecheck` passes with zero errors
## Execution order
Execute tiers sequentially: 1 → 2 → 3. Each tier builds on the previous one's patterns and fixes foundational issues before adding polish.
Detailed task briefs are in:
- Tier 1: `docs/tasks/1a-badges-avatars.md`
- Tier 2: `docs/tasks/1b-skeleton-tabs-toasts.md`
- Tier 3: `docs/tasks/1c-emptystate-switch.md`
+5 -4
View File
@@ -11,6 +11,7 @@ import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab"; import { DashboardTab } from "@/components/dashboard-tab";
import { DashboardGreeting } from "@/components/dashboard-greeting"; import { DashboardGreeting } from "@/components/dashboard-greeting";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { dashboards } from "@/modules/_core/schema"; import { dashboards } from "@/modules/_core/schema";
@@ -113,10 +114,10 @@ export default async function DashboardPage({
<CardContent> <CardContent>
<Suspense <Suspense
fallback={ fallback={
<div className="animate-pulse space-y-2"> <div className="space-y-3">
<div className="h-3 w-3/4 rounded bg-muted" /> <Skeleton className="h-4 w-3/4" />
<div className="h-3 w-1/2 rounded bg-muted" /> <Skeleton className="h-4 w-1/2" />
<div className="h-3 w-2/3 rounded bg-muted" /> <Skeleton className="h-4 w-2/3" />
</div> </div>
} }
> >
+3 -1
View File
@@ -67,6 +67,7 @@
--radius-md: var(--r-md); --radius-md: var(--r-md);
--radius-lg: var(--r-lg); --radius-lg: var(--r-lg);
--radius-xl: var(--r-xl); --radius-xl: var(--r-xl);
--color-ok-dark: var(--c-success);
} }
/* ── Paper-and-ink primitives (shared across all palettes) ────────── */ /* ── Paper-and-ink primitives (shared across all palettes) ────────── */
@@ -98,7 +99,7 @@
--ok: #4f7a3f; --ok: #4f7a3f;
--warn: #c99a3f; --warn: #c99a3f;
--bad: #b05246; --bad: #b05246;
--c-success: var(--ok);
/* Density (regular by default; overridden via [data-density]) */ /* Density (regular by default; overridden via [data-density]) */
--row: 48px; --row: 48px;
--pad: 14px; --pad: 14px;
@@ -342,6 +343,7 @@
--ok: #82b070; --ok: #82b070;
--warn: #dcb46a; --warn: #dcb46a;
--bad: #d8746a; --bad: #d8746a;
--c-success: var(--ok);
--background: var(--paper); --background: var(--paper);
--foreground: var(--ink); --foreground: var(--ink);
+2
View File
@@ -15,6 +15,7 @@ import { CommandPalette } from "@/components/command-palette";
import { PwaRegister } from "@/components/pwa-register"; import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt"; import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes"; import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } 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 /> <CommandPalette />
<InstallPrompt /> <InstallPrompt />
<PwaRegister /> <PwaRegister />
<AppToaster position="bottom-right" />
</QuickAddProvider> </QuickAddProvider>
</body> </body>
</html> </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." />; 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) { if (!data) {
recordFailure(rlKey); recordFailure(rlKey);
return <ShareError />; 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 { NotifyChannelToggles } from "@/components/notify-channel-toggles";
import { ResponsiveSidebar } from "@/components/settings-section"; import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section"; import type { SectionId } from "@/components/settings-section";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions"; import { revokeShareLinkAction } from "./actions";
import { listCalendars } from "@/modules/calendar/server/queries"; import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/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]"> <div className="grid gap-5 sm:grid-cols-[220px_1fr]">
<ResponsiveSidebar active={section} /> <ResponsiveSidebar active={section} />
<div className="space-y-4"> <div className="space-y-4">
{section === "household" && <HouseholdSection household={household} userName={user.name} />} {section === "household" && <HouseholdSection household={household} user={user} />}
{section === "sharing" && <SharingSection />} {section === "sharing" && <SharingSection />}
{section === "notifications" && ( {section === "notifications" && (
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} /> <NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
@@ -58,10 +59,10 @@ export default async function SettingsPage({
function HouseholdSection({ function HouseholdSection({
household, household,
userName, user,
}: { }: {
household: { id: string; name: string }; household: { id: string; name: string };
userName: string | null; user: { name: string | null; image: string | null };
}) { }) {
return ( return (
<> <>
@@ -91,11 +92,9 @@ function HouseholdSection({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}> <div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}> <AvatarFallbackWithName name={user.name ?? undefined} image={user.image} />
{(userName ?? "?").trim()[0]?.toUpperCase()}
</span>
<div className="label"> <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 className="d">Signed in via Authentik</div>
</div> </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)} onClick={() => setPresetMenuOpen((o) => !o)}
disabled={isPending} disabled={isPending}
> >
<LayoutGrid className="size-4 mr-1" /> <LayoutGrid data-icon="inline-start" />
Preset Preset
</Button> </Button>
{presetMenuOpen && ( {presetMenuOpen && (
@@ -173,7 +173,7 @@ export function DashboardEditor({
)} )}
</div> </div>
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}> <Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
<RotateCcw className="size-4 mr-1" /> <RotateCcw data-icon="inline-start" />
Reset Reset
</Button> </Button>
<Button <Button
@@ -182,7 +182,7 @@ export function DashboardEditor({
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
disabled={isPending} disabled={isPending}
> >
<Plus className="size-4 mr-1" /> <Plus data-icon="inline-start" />
Add widget Add widget
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isPending}> <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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -168,28 +169,32 @@ function DashboardKebab({
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRenaming(true)}> <DropdownMenuGroup>
<PenLine className="size-4 mr-2" /> <DropdownMenuItem onSelect={() => setRenaming(true)}>
Rename <PenLine />
</DropdownMenuItem> Rename
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star className="size-4 mr-2" />
Set as default
</DropdownMenuItem> </DropdownMenuItem>
)} {!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star />
Set as default
</DropdownMenuItem>
)}
</DropdownMenuGroup>
{canDelete && ( {canDelete && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuGroup>
className="text-destructive focus:text-destructive" <DropdownMenuItem
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))} className="text-destructive focus:text-destructive"
> onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
<Trash2 className="size-4 mr-2" /> >
Delete <Trash2 />
</DropdownMenuItem> Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
+3 -4
View File
@@ -2,6 +2,7 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { setNotifChannel } from "@/app/settings/notify-actions"; import { setNotifChannel } from "@/app/settings/notify-actions";
import { Switch } from "@/components/ui/switch";
type Channel = "push" | "inapp" | "ntfy"; 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 className="ml-2 text-xs text-muted-foreground">(NTFY_URL not configured)</span>
)} )}
</span> </span>
<input <Switch
type="checkbox"
checked={value} checked={value}
disabled={isPending || disabled} disabled={isPending || disabled}
onChange={(e) => toggle(key, e.target.checked)} onCheckedChange={(checked) => toggle(key, checked)}
className="size-4 cursor-pointer"
/> />
</label> </label>
))} ))}
+1 -1
View File
@@ -101,7 +101,7 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
if (status === "subscribed") { if (status === "subscribed") {
return ( return (
<div className="flex items-center gap-3"> <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 Push notifications enabled
</span> </span>
<Button size="sm" variant="outline" onClick={sendTest} disabled={isPending}> <Button size="sm" variant="outline" onClick={sendTest} disabled={isPending}>
+7 -3
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Link, Check, Copy } from "lucide-react"; import { Link, Check, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -32,7 +33,9 @@ export function ShareButton({
setShareUrl(result.url); setShareUrl(result.url);
setOpen(true); setOpen(true);
} catch (err) { } 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; if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => { navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true); setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}); });
} }
@@ -49,7 +53,7 @@ export function ShareButton({
<> <>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<Button variant="outline" onClick={share} disabled={isPending}> <Button variant="outline" onClick={share} disabled={isPending}>
<Link /> <Link data-icon="inline-start" />
Share Share
</Button> </Button>
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
@@ -67,7 +71,7 @@ export function ShareButton({
<div className="flex gap-2"> <div className="flex gap-2">
<Input readOnly value={shareUrl ?? ""} className="font-mono text-xs" /> <Input readOnly value={shareUrl ?? ""} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={copyUrl} aria-label="Copy link"> <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> </Button>
</div> </div>
</DialogContent> </DialogContent>
+12 -17
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { cn } from "@/lib/utils";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getRegistry } from "@/modules/_core/registry"; 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 { BrandMark } from "@/components/brand-mark";
import { NavIcon } from "@/components/nav-icon"; import { NavIcon } from "@/components/nav-icon";
import { NavLink } from "@/components/nav-link"; import { NavLink } from "@/components/nav-link";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
type Variant = "side" | "top"; type Variant = "side" | "top";
@@ -102,23 +104,16 @@ function HouseholdPill({
return ( return (
<div className="household-pill" title={info.household.name}> <div className="household-pill" title={info.household.name}>
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
{visible.map((m, i) => { {visible.map((m, i) => (
const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?"; <AvatarFallbackWithName
return ( key={m.id}
<span name={m.name ?? m.email ?? undefined}
key={m.id} image={m.image}
className="avatar avatar-sm" className={cn(i > 0 && "-ml-1.5", "shadow-[0_0_0_1.5px_var(--card)]")}
style={{ fallbackStyle={{ background: avatarColor(m.id) }}
background: avatarColor(m.id), title={m.name ?? m.email ?? undefined}
marginLeft: i > 0 ? -6 : 0, />
boxShadow: "0 0 0 1.5px var(--card)", ))}
}}
title={m.name ?? m.email ?? undefined}
>
{initial}
</span>
);
})}
</div> </div>
<div className="household-text" style={{ minWidth: 0 }}> <div className="household-text" style={{ minWidth: 0 }}>
<div <div
+63 -33
View File
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
@@ -41,6 +42,11 @@ interface Props {
signedIn?: boolean; 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({ export function ThemePicker({
initialPalette = "clay", initialPalette = "clay",
initialMode = "system", initialMode = "system",
@@ -68,7 +74,11 @@ export function ThemePicker({
<div className="space-y-5"> <div className="space-y-5">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Theme</Label> <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"> <SelectTrigger className="w-56">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span <span
@@ -84,20 +94,22 @@ export function ThemePicker({
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{PALETTES.map((p) => ( <SelectGroup>
<SelectItem key={p.id} value={p.id}> {PALETTES.map((p) => (
<div className="flex items-center justify-between gap-6 w-40"> <SelectItem key={p.id} value={p.id}>
<span>{p.label}</span> <div className="flex items-center justify-between gap-6 w-40">
<span <span>{p.label}</span>
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]" <span
style={{ className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`, style={{
borderColor: "var(--hair-2)", background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
}} borderColor: "var(--hair-2)",
/> }}
</div> />
</SelectItem> </div>
))} </SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -121,16 +133,22 @@ export function ThemePicker({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Type pairing</Label> <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"> <SelectTrigger className="w-72">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FONT_PAIRS.map((f) => ( <SelectGroup>
<SelectItem key={f.id} value={f.id}> {FONT_PAIRS.map((f) => (
{f.label} <SelectItem key={f.id} value={f.id}>
</SelectItem> {f.label}
))} </SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -154,16 +172,22 @@ export function ThemePicker({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Dashboard layout</Label> <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"> <SelectTrigger className="w-72">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{DASH_LAYOUTS.map((d) => ( <SelectGroup>
<SelectItem key={d.id} value={d.id}> {DASH_LAYOUTS.map((d) => (
{d.label} <SelectItem key={d.id} value={d.id}>
</SelectItem> {d.label}
))} </SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -187,16 +211,22 @@ export function ThemePicker({
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Navigation</Label> <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"> <SelectTrigger className="w-72">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{NAV_STYLES.map((n) => ( <SelectGroup>
<SelectItem key={n.id} value={n.id}> {NAV_STYLES.map((n) => (
{n.label} <SelectItem key={n.id} value={n.id}>
</SelectItem> {n.label}
))} </SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <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 { TopbarSearch } from "@/components/topbar-search";
import { TopbarTitle } from "@/components/topbar-title"; import { TopbarTitle } from "@/components/topbar-title";
import { TopbarNewButton } from "@/components/topbar-new-button"; import { TopbarNewButton } from "@/components/topbar-new-button";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
async function getNotifications(userId: string) { async function getNotifications(userId: string) {
const rows = await db const rows = await db
@@ -35,7 +36,6 @@ export async function Topbar() {
? await getNotifications(userId) ? await getNotifications(userId)
: { rows: [], unread: 0 }; : { rows: [], unread: 0 };
const userRow = userId ? await getUserAvatar(userId) : null; const userRow = userId ? await getUserAvatar(userId) : null;
const initial = (userRow?.name ?? userRow?.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
return ( return (
<div className="topbar"> <div className="topbar">
@@ -56,22 +56,12 @@ export async function Topbar() {
)} )}
<TopbarNewButton /> <TopbarNewButton />
{userId && ( {userId && (
<span <AvatarFallbackWithName
className="avatar" name={userRow?.name ?? userRow?.email ?? undefined}
style={{ width: 28, height: 28, fontSize: 12, background: "var(--c-household)" }} image={userRow?.image}
size="sm"
title={userRow?.name ?? userRow?.email ?? undefined} 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>
</div> </div>
+11 -1
View File
@@ -17,6 +17,15 @@ export type ShareCapabilities = {
defaultCapabilities?: string[]; defaultCapabilities?: string[];
}; };
export type ShareContext = {
householdId: string;
userId: string;
};
export type PublicShareContext = {
householdId: string;
};
export type ReminderCapabilities = { export type ReminderCapabilities = {
canRemind: boolean; canRemind: boolean;
}; };
@@ -68,7 +77,8 @@ export type EntityTypeRegistration = {
reminder?: ReminderCapabilities; reminder?: ReminderCapabilities;
search?: SearchAdapter; search?: SearchAdapter;
resolveUrl: (id: string) => string; 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: { renderSharedView?: (props: {
data: unknown; data: unknown;
capabilities: { read: boolean; write: boolean }; 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 { getCurrentSession } from "@/lib/session";
import { getEntityType } from "./registry"; import { getEntityType } from "./registry";
import { shareLinks } from "./schema"; import { shareLinks } from "./schema";
import { ensureEntityShareAuthorized } from "./share-authorization";
export type ShareLinkCapabilities = { read: boolean; write: boolean }; export type ShareLinkCapabilities = { read: boolean; write: boolean };
@@ -36,6 +37,10 @@ export async function createShareLink(
} }
const { user, household } = await getCurrentSession(); const { user, household } = await getCurrentSession();
await ensureEntityShareAuthorized(registration, entityId, {
householdId: household.id,
userId: user.id,
});
const rawToken = randomBytes(32).toString("base64url"); const rawToken = randomBytes(32).toString("base64url");
const tokenHash = hashToken(rawToken); const tokenHash = hashToken(rawToken);
@@ -17,6 +17,7 @@ import type { CalView } from "@/modules/_core/themes";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
@@ -48,6 +49,10 @@ type EventDraft = {
type MobileView = "day" | "week" | "agenda"; type MobileView = "day" | "week" | "agenda";
const DEFAULT_COLOR = "#B85C3C"; const DEFAULT_COLOR = "#B85C3C";
const VISIBILITY_ITEMS = [
{ label: "Household", value: "household" },
{ label: "Private", value: "private" },
];
const VIEW_MAP: Record<CalView, string> = { const VIEW_MAP: Record<CalView, string> = {
month: "dayGridMonth", month: "dayGridMonth",
@@ -115,6 +120,10 @@ export function CalendarShell({
() => eventRows.filter((event) => visibleIds.has(event.calendarId)), () => eventRows.filter((event) => visibleIds.has(event.calendarId)),
[eventRows, visibleIds], [eventRows, visibleIds],
); );
const calendarSelectItems = useMemo(
() => calendarRows.map((calendar) => ({ label: calendar.name, value: calendar.id })),
[calendarRows],
);
function toggleCalendar(id: string) { function toggleCalendar(id: string) {
setVisibleIds((current) => { setVisibleIds((current) => {
@@ -343,6 +352,7 @@ export function CalendarShell({
onChange={(event) => updateCalendar(calendar, { color: event.target.value })} onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
/> />
<Select <Select
items={VISIBILITY_ITEMS}
value={calendar.visibility} value={calendar.visibility}
onValueChange={(value) => onValueChange={(value) =>
updateCalendar(calendar, { visibility: value as "private" | "household" }) updateCalendar(calendar, { visibility: value as "private" | "household" })
@@ -354,8 +364,13 @@ export function CalendarShell({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="household">Household</SelectItem> <SelectGroup>
<SelectItem value="private">Private</SelectItem> {VISIBILITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
@@ -391,6 +406,7 @@ export function CalendarShell({
onChange={(event) => setCalendarColorValue(event.target.value)} onChange={(event) => setCalendarColorValue(event.target.value)}
/> />
<Select <Select
items={VISIBILITY_ITEMS}
value={calendarVisibility} value={calendarVisibility}
onValueChange={(value) => onValueChange={(value) =>
setCalendarVisibilityValue(value as "private" | "household") setCalendarVisibilityValue(value as "private" | "household")
@@ -402,8 +418,13 @@ export function CalendarShell({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="household">Household</SelectItem> <SelectGroup>
<SelectItem value="private">Private</SelectItem> {VISIBILITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -444,6 +465,7 @@ export function CalendarShell({
/> />
<Label htmlFor="event-calendar">Calendar</Label> <Label htmlFor="event-calendar">Calendar</Label>
<Select <Select
items={calendarSelectItems}
value={selectedEvent.calendarId} value={selectedEvent.calendarId}
onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })} onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
> >
@@ -454,11 +476,13 @@ export function CalendarShell({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{calendarRows.map((calendar) => ( <SelectGroup>
<SelectItem key={calendar.id} value={calendar.id}> {calendarSelectItems.map((calendar) => (
{calendar.name} <SelectItem key={calendar.value} value={calendar.value}>
</SelectItem> {calendar.label}
))} </SelectItem>
))}
</SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="grid gap-3 sm:grid-cols-2"> <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 { z } from "zod";
import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries"; import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries";
import { import {
canShareCalendar,
canShareEvent,
loadCalendarForShare, loadCalendarForShare,
loadEventForShare, loadEventForShare,
type CalendarShareData, type CalendarShareData,
@@ -150,7 +152,8 @@ const manifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] }, share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchCalendars }, search: { search: searchCalendars },
resolveUrl: (id) => `/calendar?id=${id}`, resolveUrl: (id) => `/calendar?id=${id}`,
loadForShare: (id) => loadCalendarForShare(id), canShareEntity: canShareCalendar,
loadForShare: loadCalendarForShare,
renderSharedView: ({ data }) => <CalendarSharedView data={data as CalendarShareData} />, renderSharedView: ({ data }) => <CalendarSharedView data={data as CalendarShareData} />,
renderActivity: (entry) => { renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined; const name = entry.payload?.name as string | undefined;
@@ -166,7 +169,8 @@ const manifest: ModuleManifest = {
reminder: { canRemind: true }, reminder: { canRemind: true },
search: { search: searchEvents }, search: { search: searchEvents },
resolveUrl: (id) => `/calendar/events/${id}`, resolveUrl: (id) => `/calendar/events/${id}`,
loadForShare: (id) => loadEventForShare(id), canShareEntity: canShareEvent,
loadForShare: loadEventForShare,
renderSharedView: ({ data }) => <EventSharedView data={data as EventShareData} />, renderSharedView: ({ data }) => <EventSharedView data={data as EventShareData} />,
renderActivity: (entry) => { renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined; 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 { and, asc, eq, gte, lte } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { calendarEvents, calendars } from "../schema"; import { calendarEvents, calendars } from "../schema";
export type EventSummary = { export type EventSummary = {
@@ -21,11 +22,51 @@ export type CalendarShareData = {
export type EventShareData = EventSummary & { calendarName: string }; 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 const [calendar] = await db
.select({ id: calendars.id, name: calendars.name, color: calendars.color }) .select({ id: calendars.id, name: calendars.name, color: calendars.color })
.from(calendars) .from(calendars)
.where(eq(calendars.id, id)) .where(and(eq(calendars.id, id), eq(calendars.householdId, ctx.householdId)))
.limit(1); .limit(1);
if (!calendar) return null; 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 const [row] = await db
.select({ .select({
id: calendarEvents.id, id: calendarEvents.id,
@@ -77,7 +121,7 @@ export async function loadEventForShare(id: string): Promise<EventShareData | nu
}) })
.from(calendarEvents) .from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .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); .limit(1);
if (!row) return null; if (!row) return null;
@@ -1,8 +1,8 @@
"use client"; "use client";
import Link from "next/link";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { import {
deleteCareSchedule, deleteCareSchedule,
scheduleOnCalendar, scheduleOnCalendar,
@@ -35,7 +35,6 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
const [calendarOpenId, setCalendarOpenId] = useState<string | null>(null); const [calendarOpenId, setCalendarOpenId] = useState<string | null>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? ""); const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? "");
const [reminderMinutes, setReminderMinutes] = useState(""); const [reminderMinutes, setReminderMinutes] = useState("");
const [calendarSuccess, setCalendarSuccess] = useState<string | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const router = useRouter(); const router = useRouter();
@@ -56,6 +55,7 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
router.refresh(); router.refresh();
} catch { } catch {
setFormError("Failed to save schedule."); 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, reminderMinutesBefore: reminderMinutes ? parseInt(reminderMinutes, 10) : undefined,
}); });
setCalendarOpenId(null); setCalendarOpenId(null);
setCalendarSuccess(scheduleId); toast.success("Event added to calendar");
setTimeout(() => setCalendarSuccess(null), 4000);
} catch (err) { } 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"} title={s.enabled ? "Disable" : "Enable"}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${ className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
s.enabled 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)]" : "border-[var(--ink-faint)] text-[var(--ink-mute)]"
}`} }`}
> >
@@ -256,15 +257,6 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
</div> </div>
</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>
))} ))}
</div> </div>
+134 -117
View File
@@ -2,9 +2,14 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ShareButton } from "@/components/share-button"; import { ShareButton } from "@/components/share-button";
import { ShareLinkList } from "@/components/share-link-list"; import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share"; 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 { import {
addContainerImage, addContainerImage,
deleteContainer, deleteContainer,
@@ -32,9 +37,14 @@ export function ContainerDetail({ container, shareLinks }: Props) {
function handleDelete() { function handleDelete() {
startTransition(async () => { startTransition(async () => {
await deleteContainer({ id: container.id }); try {
router.push("/garden"); await deleteContainer({ id: container.id });
router.refresh(); 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(); router.refresh();
} catch { } catch {
setGalleryError("Image upload failed."); setGalleryError("Image upload failed.");
toast.error("Image upload failed");
} finally { } finally {
setUploadingImage(false); setUploadingImage(false);
e.target.value = ""; e.target.value = "";
@@ -137,130 +148,136 @@ export function ContainerDetail({ container, shareLinks }: Props) {
)} )}
{/* Tabs */} {/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]"> <Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
{(["info", "gallery"] as Tab[]).map((t) => ( <TabsList variant="line">
<button <TabsTrigger value="info">Info</TabsTrigger>
key={t} <TabsTrigger value="gallery">Gallery</TabsTrigger>
onClick={() => setTab(t)} </TabsList>
className={`pb-2 text-sm font-medium capitalize transition-colors ${ <Separator />
tab === t
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)] hover:text-[var(--ink)]"
}`}
>
{t}
</button>
))}
</div>
{/* Info */} <TabsContent value="info">
{tab === "info" && ( <div>
<div> <div className="flex items-center justify-between mb-3">
<div className="flex items-center justify-between mb-3"> <h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2> <a
<a href={`/garden/plants/new?containerId=${container.id}`}
href={`/garden/plants/new?containerId=${container.id}`} className="btn btn-ghost btn-sm"
className="btn btn-ghost btn-sm" >
> + Add plant
+ Add plant </a>
</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> </div>
{container.plants.length === 0 ? ( </TabsContent>
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
) : ( <TabsContent value="gallery">
<div className="grid gap-2 sm:grid-cols-2"> <div className="flex flex-col gap-4">
{container.plants.map((p) => ( {container.images.length === 0 ? (
<a <p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
key={p.id} ) : (
href={`/garden/plants/${p.id}`} <div className="relative">
className="card p-3 hover:bg-[var(--surface-2)] transition-colors" {uploadingImage && <Skeleton className="absolute inset-0 z-10 rounded-lg" />}
> <div className="grid grid-cols-3 gap-2">
<div className="flex items-center gap-3"> {container.images.map((url) => (
{p.primaryImageUrl && ( <div key={url} className="relative group">
<img <img
src={p.primaryImageUrl} src={url}
alt="" alt=""
className="w-10 h-10 rounded-full object-cover shrink-0" 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">
<div> <button
<p className="font-medium text-sm">{p.name}</p> onClick={() => handleSetPrimary(url)}
{p.scientificName && ( disabled={isPending}
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p> 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>
<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>
))} </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>
)} )}
<span className="text-xs text-[var(--ink-mute)]">
{container.images.length}/10 photos {galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
</span>
<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>
</div> </TabsContent>
)} </Tabs>
</div> </div>
); );
} }
@@ -3,6 +3,14 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; 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 { ContainerForm } from "./container-form";
import type { ContainerDto } from "../server/queries"; import type { ContainerDto } from "../server/queries";
@@ -35,9 +43,15 @@ export function ContainerList({ containers }: Props) {
)} )}
{containers.length === 0 && !showNew && ( {containers.length === 0 && !showNew && (
<p className="text-sm text-[var(--ink-mute)]"> <Empty className="border-none">
No containers yet. Add one to start organising your plants. <EmptyHeader>
</p> <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"> <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 { useState, useTransition } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions"; import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
import type { CalendarDto } from "../server/calendar-bridge"; import type { CalendarDto } from "../server/calendar-bridge";
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries"; import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
import { ShareButton } from "@/components/share-button"; import { ShareButton } from "@/components/share-button";
import { ShareLinkList } from "@/components/share-link-list"; import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share"; 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 { CareHistoryList } from "./care-history-list";
import { CareLogForm } from "./care-log-form"; import { CareLogForm } from "./care-log-form";
import { CareScheduleEditor } from "./care-schedule-editor"; import { CareScheduleEditor } from "./care-schedule-editor";
@@ -41,10 +46,10 @@ function InfoRow({
); );
} }
function healthBadgeClass(status: string): string { function healthBadgeVariant(status: string): "default" | "destructive" | "secondary" {
if (status === "healthy") return "badge-success"; if (status === "healthy") return "default";
if (status === "sick") return "badge-danger"; if (status === "sick") return "destructive";
return "badge-warning"; return "secondary";
} }
export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLinks }: Props) { export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLinks }: Props) {
@@ -57,9 +62,14 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
function handleDelete() { function handleDelete() {
startTransition(async () => { startTransition(async () => {
await deletePlant({ id: plant.id }); try {
router.push("/garden"); await deletePlant({ id: plant.id });
router.refresh(); 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(); router.refresh();
} catch { } catch {
setGalleryError("Image upload failed."); setGalleryError("Image upload failed.");
toast.error("Image upload failed");
} finally { } finally {
setUploadingImage(false); setUploadingImage(false);
e.target.value = ""; 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> <p className="text-sm text-[var(--ink-mute)] italic mt-0.5">{plant.scientificName}</p>
)} )}
<div className="flex flex-wrap gap-2 mt-1"> <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} {plant.healthStatus}
</span> </Badge>
{plant.growthStage && ( {plant.growthStage && (
<span className="text-xs badge badge-outline capitalize">{plant.growthStage}</span> <Badge variant="outline" className="capitalize">
{plant.growthStage}
</Badge>
)} )}
</div> </div>
</div> </div>
@@ -160,135 +173,140 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]"> <Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-0">
{(["info", "gallery", "care"] as Tab[]).map((t) => ( <TabsList variant="line">
<button <TabsTrigger value="info">Info</TabsTrigger>
key={t} <TabsTrigger value="gallery">Gallery</TabsTrigger>
onClick={() => setTab(t)} <TabsTrigger value="care">Care</TabsTrigger>
className={`pb-2 text-sm font-medium capitalize transition-colors ${ </TabsList>
tab === t <Separator />
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)]"
}`}
>
{t}
</button>
))}
</div>
{/* Info */} <TabsContent value="info">
{tab === "info" && ( <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> <InfoRow label="Category" value={plant.category} />
<InfoRow label="Category" value={plant.category} /> {plant.containerName && (
{plant.containerName && ( <InfoRow label="Container">
<InfoRow label="Container"> <Link href={`/garden/containers/${plant.containerId}`} className="underline">
<Link href={`/garden/containers/${plant.containerId}`} className="underline"> {plant.containerName}
{plant.containerName} </Link>
</Link> </InfoRow>
</InfoRow> )}
)} <InfoRow label="Acquired" value={plant.acquisitionDate} />
<InfoRow label="Acquired" value={plant.acquisitionDate} /> <InfoRow label="Sunlight" value={plant.sunlight} />
<InfoRow label="Sunlight" value={plant.sunlight} /> <InfoRow label="Watering" value={plant.wateringNotes} />
<InfoRow label="Watering" value={plant.wateringNotes} /> <InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} /> <InfoRow label="Notes" value={plant.notes} />
<InfoRow label="Notes" value={plant.notes} /> {plant.recentCareLogs.length > 0 && (
{plant.recentCareLogs.length > 0 && ( <div className="mt-2">
<div className="mt-2"> <p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1"> Recent care
Recent care </p>
</p> <ul className="text-sm space-y-1">
<ul className="text-sm space-y-1"> {plant.recentCareLogs.map((log) => (
{plant.recentCareLogs.map((log) => ( <li key={log.id} className="flex gap-2 flex-wrap">
<li key={log.id} className="flex gap-2 flex-wrap"> <span className="capitalize">{log.careType}</span>
<span className="capitalize">{log.careType}</span> <span className="text-[var(--ink-mute)]">
<span className="text-[var(--ink-mute)]"> {new Date(log.performedAt).toLocaleDateString()}
{new Date(log.performedAt).toLocaleDateString()} </span>
</span> {log.notes && <span className="text-[var(--ink-mute)]"> {log.notes}</span>}
{log.notes && <span className="text-[var(--ink-mute)]"> {log.notes}</span>} </li>
</li> ))}
))} </ul>
</ul> </div>
</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>
)} )}
<span className="text-xs text-[var(--ink-mute)]">{plant.images.length}/10 photos</span>
</div> </div>
</div> </TabsContent>
)}
{/* Care */} <TabsContent value="gallery">
{tab === "care" && ( <div className="flex flex-col gap-4 relative">
<div className="flex flex-col gap-6"> {plant.images.length === 0 ? (
<CareScheduleEditor plantId={plant.id} schedules={careSchedules} calendars={calendars} /> <p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
<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"> <div className="relative">
Log care {uploadingImage && <Skeleton className="absolute inset-0 z-10 rounded-lg" />}
</p> <div className="grid grid-cols-3 gap-2">
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} /> {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>
<div className="border-t border-[var(--ink-faint)] pt-4"> </TabsContent>
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
History <TabsContent value="care">
</p> <div className="flex flex-col gap-6">
<CareHistoryList logs={careLogs} /> <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>
</div> </TabsContent>
)} </Tabs>
</div> </div>
); );
} }
+18 -4
View File
@@ -1,7 +1,15 @@
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import type { PlantListItemDto } from "../server/queries"; import type { PlantListItemDto } from "../server/queries";
import { Sprout } from "lucide-react";
type Props = { type Props = {
plants: PlantListItemDto[]; plants: PlantListItemDto[];
@@ -18,12 +26,18 @@ function daysAgo(isoString: string): string {
export function PlantList({ plants }: Props) { export function PlantList({ plants }: Props) {
if (plants.length === 0) { if (plants.length === 0) {
return ( return (
<div className="flex flex-col items-center gap-3 py-8 text-center"> <Empty className="border-none py-8">
<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p> <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"> <Link href="/garden/plants/new" className="btn btn-primary btn-sm">
Add your first plant Add your first plant
</Link> </Link>
</div> </Empty>
); );
} }
@@ -96,7 +110,7 @@ export function PlantList({ plants }: Props) {
)} )}
</div> </div>
{plant.hasOverdueCare && ( {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>
</div> </div>
@@ -12,9 +12,8 @@ const CARE_ICONS: Record<string, string> = {
}; };
function urgencyLabel(days: number): { text: string; cls: string } { function urgencyLabel(days: number): { text: string; cls: string } {
if (days < 0) if (days < 0) return { text: `${Math.abs(days)}d overdue`, cls: "text-[var(--bad)]" };
return { text: `${Math.abs(days)}d overdue`, cls: "text-red-600 dark:text-red-400" }; if (days === 0) return { text: "due today", cls: "text-[var(--warn)]" };
if (days === 0) return { text: "due today", cls: "text-amber-600 dark:text-amber-400" };
return { text: `in ${days}d`, cls: "text-[var(--ink-mute)]" }; return { text: `in ${days}d`, cls: "text-[var(--ink-mute)]" };
} }
@@ -108,7 +107,7 @@ export function GardenOverviewWidget({ stats }: { stats: GardenOverviewStats })
</div> </div>
{stats.overdueCount > 0 && ( {stats.overdueCount > 0 && (
<div className="flex flex-col items-center"> <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} {stats.overdueCount}
</span> </span>
<span className="text-xs text-[var(--ink-mute)] mt-0.5">overdue</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 { registerItemToggleHook } from "../_core/registry";
import type { ModuleManifest, WidgetContext } from "../_core/module"; import type { ModuleManifest, WidgetContext } from "../_core/module";
import { import {
canShareContainer,
canSharePlant,
loadContainerForShare, loadContainerForShare,
loadPlantForShare, loadPlantForShare,
type ContainerShareData, type ContainerShareData,
@@ -45,7 +47,8 @@ const gardenManifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] }, share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchPlants }, search: { search: searchPlants },
resolveUrl: (id) => `/garden/plants/${id}`, resolveUrl: (id) => `/garden/plants/${id}`,
loadForShare: (id) => loadPlantForShare(id), canShareEntity: canSharePlant,
loadForShare: loadPlantForShare,
renderSharedView: ({ data }) => { renderSharedView: ({ data }) => {
const d = data as PlantShareData; const d = data as PlantShareData;
return ( return (
@@ -109,7 +112,8 @@ const gardenManifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] }, share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchContainers }, search: { search: searchContainers },
resolveUrl: (id) => `/garden/containers/${id}`, resolveUrl: (id) => `/garden/containers/${id}`,
loadForShare: (id) => loadContainerForShare(id), canShareEntity: canShareContainer,
loadForShare: loadContainerForShare,
renderSharedView: ({ data }) => { renderSharedView: ({ data }) => {
const d = data as ContainerShareData; const d = data as ContainerShareData;
return ( return (
+32 -5
View File
@@ -1,5 +1,6 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { gardenContainers, gardenPlants } from "../schema"; import { gardenContainers, gardenPlants } from "../schema";
export type ContainerShareData = { export type ContainerShareData = {
@@ -27,7 +28,30 @@ export type PlantShareData = {
sunlight: string | null; 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 const [plant] = await db
.select({ .select({
id: gardenPlants.id, id: gardenPlants.id,
@@ -44,18 +68,21 @@ export async function loadPlantForShare(id: string): Promise<PlantShareData | nu
sunlight: gardenPlants.sunlight, sunlight: gardenPlants.sunlight,
}) })
.from(gardenPlants) .from(gardenPlants)
.where(eq(gardenPlants.id, id)) .where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, ctx.householdId)))
.limit(1); .limit(1);
if (!plant) return null; if (!plant) return null;
return plant; 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 const [container] = await db
.select() .select()
.from(gardenContainers) .from(gardenContainers)
.where(eq(gardenContainers.id, id)) .where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, ctx.householdId)))
.limit(1); .limit(1);
if (!container) return null; if (!container) return null;
@@ -67,7 +94,7 @@ export async function loadContainerForShare(id: string): Promise<ContainerShareD
scientificName: gardenPlants.scientificName, scientificName: gardenPlants.scientificName,
}) })
.from(gardenPlants) .from(gardenPlants)
.where(and(eq(gardenPlants.containerId, id))); .where(and(eq(gardenPlants.containerId, id), eq(gardenPlants.householdId, ctx.householdId)));
return { return {
id: container.id, id: container.id,
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ModuleManifest, WidgetContext } from "../_core/module"; import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod"; import { z } from "zod";
import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries"; 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 { ListSharedView } from "./components/shared-view";
import { ListWidget } from "./components/list-widget"; import { ListWidget } from "./components/list-widget";
@@ -34,7 +34,8 @@ const manifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read", "write"] }, share: { canShare: true, defaultCapabilities: ["read", "write"] },
search: { search: searchLists }, search: { search: searchLists },
resolveUrl: (id) => `/lists/${id}`, resolveUrl: (id) => `/lists/${id}`,
loadForShare: (id) => loadListForShare(id), canShareEntity: canShareList,
loadForShare: loadListForShare,
renderSharedView: ({ data, capabilities, token }) => ( renderSharedView: ({ data, capabilities, token }) => (
<ListSharedView data={data as ListShareData} canWrite={capabilities.write} token={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 { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { listItems, lists } from "../schema"; import { listItems, lists } from "../schema";
export type ListShareItem = { export type ListShareItem = {
@@ -19,7 +20,20 @@ export type ListShareData = {
items: ListShareItem[]; 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 const [list] = await db
.select({ .select({
id: lists.id, id: lists.id,
@@ -28,7 +42,7 @@ export async function loadListForShare(id: string): Promise<ListShareData | null
householdId: lists.householdId, householdId: lists.householdId,
}) })
.from(lists) .from(lists)
.where(eq(lists.id, id)) .where(and(eq(lists.id, id), eq(lists.householdId, ctx.householdId)))
.limit(1); .limit(1);
if (!list) return null; if (!list) return null;
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ModuleManifest, WidgetContext } from "../_core/module"; import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod"; import { z } from "zod";
import { listWidgetNotes, searchNotes } from "./server/queries"; 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"; import { NoteSharedView } from "./components/shared-view";
const notesWidgetConfigSchema = z.object({ const notesWidgetConfigSchema = z.object({
@@ -72,7 +72,8 @@ const manifest: ModuleManifest = {
reminder: { canRemind: true }, reminder: { canRemind: true },
search: { search: searchNotes }, search: { search: searchNotes },
resolveUrl: (id) => `/notes/${id}`, resolveUrl: (id) => `/notes/${id}`,
loadForShare: (id) => loadNoteForShare(id), canShareEntity: canShareNote,
loadForShare: loadNoteForShare,
renderSharedView: ({ data }) => <NoteSharedView data={data as NoteShareData} />, renderSharedView: ({ data }) => <NoteSharedView data={data as NoteShareData} />,
renderActivity: (entry) => { renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined; 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 { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { notes } from "../schema"; import { notes } from "../schema";
export type NoteShareData = { export type NoteShareData = {
@@ -10,7 +11,20 @@ export type NoteShareData = {
updatedAt: string; 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 const [note] = await db
.select({ .select({
id: notes.id, id: notes.id,
@@ -20,7 +34,7 @@ export async function loadNoteForShare(id: string): Promise<NoteShareData | null
updatedAt: notes.updatedAt, updatedAt: notes.updatedAt,
}) })
.from(notes) .from(notes)
.where(eq(notes.id, id)) .where(and(eq(notes.id, id), eq(notes.householdId, ctx.householdId)))
.limit(1); .limit(1);
if (!note) return null; if (!note) return null;
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { ensureEntityShareAuthorized } from "../../src/modules/_core/share-authorization";
import type { EntityTypeRegistration, ShareContext } from "../../src/modules/_core/module";
const ctx: ShareContext = {
householdId: "household-1",
userId: "user-1",
};
function registration(
canShareEntity?: EntityTypeRegistration["canShareEntity"],
): EntityTypeRegistration {
return {
type: "notes.note",
label: { singular: "Note", plural: "Notes" },
share: { canShare: true },
resolveUrl: (id) => `/notes/${id}`,
canShareEntity,
};
}
describe("ensureEntityShareAuthorized", () => {
it("rejects shareable entity types that do not provide entity authorization", async () => {
await assert.rejects(
() => ensureEntityShareAuthorized(registration(), "note-1", ctx),
/does not support share authorization/,
);
});
it("rejects entities denied by their module authorization callback", async () => {
await assert.rejects(
() =>
ensureEntityShareAuthorized(
registration(async () => false),
"note-1",
ctx,
),
/not allowed to share this entity/,
);
});
it("allows entities approved by their module authorization callback", async () => {
await ensureEntityShareAuthorized(
registration(async () => true),
"note-1",
ctx,
);
});
});