fix: tighten sharing and shadcn composition
This commit is contained in:
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -0,0 +1,114 @@
|
||||
# 76 — Shadcn component implementation (Tiers 1–3)
|
||||
|
||||
## 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`
|
||||
@@ -11,6 +11,7 @@ import { DashboardSwitcher } from "@/components/dashboard-switcher";
|
||||
import { DashboardTab } from "@/components/dashboard-tab";
|
||||
import { DashboardGreeting } from "@/components/dashboard-greeting";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { dashboards } from "@/modules/_core/schema";
|
||||
@@ -113,10 +114,10 @@ export default async function DashboardPage({
|
||||
<CardContent>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3 w-3/4 rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-2/3 rounded bg-muted" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
+3
-1
@@ -67,6 +67,7 @@
|
||||
--radius-md: var(--r-md);
|
||||
--radius-lg: var(--r-lg);
|
||||
--radius-xl: var(--r-xl);
|
||||
--color-ok-dark: var(--c-success);
|
||||
}
|
||||
|
||||
/* ── Paper-and-ink primitives (shared across all palettes) ────────── */
|
||||
@@ -98,7 +99,7 @@
|
||||
--ok: #4f7a3f;
|
||||
--warn: #c99a3f;
|
||||
--bad: #b05246;
|
||||
|
||||
--c-success: var(--ok);
|
||||
/* Density (regular by default; overridden via [data-density]) */
|
||||
--row: 48px;
|
||||
--pad: 14px;
|
||||
@@ -342,6 +343,7 @@
|
||||
--ok: #82b070;
|
||||
--warn: #dcb46a;
|
||||
--bad: #d8746a;
|
||||
--c-success: var(--ok);
|
||||
|
||||
--background: var(--paper);
|
||||
--foreground: var(--ink);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CommandPalette } from "@/components/command-palette";
|
||||
import { PwaRegister } from "@/components/pwa-register";
|
||||
import { InstallPrompt } from "@/components/install-prompt";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { AppToaster } from "@/components/app-toaster";
|
||||
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
|
||||
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
|
||||
|
||||
@@ -169,6 +170,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<CommandPalette />
|
||||
<InstallPrompt />
|
||||
<PwaRegister />
|
||||
<AppToaster position="bottom-right" />
|
||||
</QuickAddProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -38,7 +38,9 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
|
||||
return <ShareError message="This content type cannot be shared." />;
|
||||
}
|
||||
|
||||
const data = await entityReg.loadForShare(resolved.entityId);
|
||||
const data = await entityReg.loadForShare(resolved.entityId, {
|
||||
householdId: resolved.householdId,
|
||||
});
|
||||
if (!data) {
|
||||
recordFailure(rlKey);
|
||||
return <ShareError />;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PushOptIn } from "@/components/push-opt-in";
|
||||
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
|
||||
import { ResponsiveSidebar } from "@/components/settings-section";
|
||||
import type { SectionId } from "@/components/settings-section";
|
||||
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
||||
import { revokeShareLinkAction } from "./actions";
|
||||
import { listCalendars } from "@/modules/calendar/server/queries";
|
||||
import { listLists } from "@/modules/lists/server/queries";
|
||||
@@ -43,7 +44,7 @@ export default async function SettingsPage({
|
||||
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
|
||||
<ResponsiveSidebar active={section} />
|
||||
<div className="space-y-4">
|
||||
{section === "household" && <HouseholdSection household={household} userName={user.name} />}
|
||||
{section === "household" && <HouseholdSection household={household} user={user} />}
|
||||
{section === "sharing" && <SharingSection />}
|
||||
{section === "notifications" && (
|
||||
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
|
||||
@@ -58,10 +59,10 @@ export default async function SettingsPage({
|
||||
|
||||
function HouseholdSection({
|
||||
household,
|
||||
userName,
|
||||
user,
|
||||
}: {
|
||||
household: { id: string; name: string };
|
||||
userName: string | null;
|
||||
user: { name: string | null; image: string | null };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -91,11 +92,9 @@ function HouseholdSection({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
|
||||
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
|
||||
{(userName ?? "?").trim()[0]?.toUpperCase()}
|
||||
</span>
|
||||
<AvatarFallbackWithName name={user.name ?? undefined} image={user.image} />
|
||||
<div className="label">
|
||||
<div className="t">{userName ?? "Anonymous"}</div>
|
||||
<div className="t">{user.name ?? "Anonymous"}</div>
|
||||
<div className="d">Signed in via Authentik</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export function AppToaster({ ...props }: ToasterProps) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
|
||||
};
|
||||
sync();
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AvatarFallbackWithName({
|
||||
name,
|
||||
initial,
|
||||
image,
|
||||
size = "default",
|
||||
className,
|
||||
fallbackStyle,
|
||||
title,
|
||||
}: {
|
||||
name?: string;
|
||||
initial?: string;
|
||||
image?: string | null;
|
||||
size?: "sm" | "default" | "lg";
|
||||
className?: string;
|
||||
fallbackStyle?: React.CSSProperties;
|
||||
title?: string;
|
||||
}) {
|
||||
const computedInitial = initial ?? name?.trim()[0]?.toUpperCase() ?? "?";
|
||||
const sizeClass =
|
||||
size === "sm" ? "size-7 text-xs" : size === "lg" ? "size-10 text-base" : "size-8 text-sm";
|
||||
return (
|
||||
<span title={title}>
|
||||
<Avatar className={cn(sizeClass, className)}>
|
||||
{image ? (
|
||||
<AvatarImage src={image} alt={name ?? ""} />
|
||||
) : (
|
||||
<AvatarFallback
|
||||
style={fallbackStyle}
|
||||
className={cn(!fallbackStyle && "bg-[var(--c-household)]")}
|
||||
>
|
||||
{computedInitial}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export function DashboardEditor({
|
||||
onClick={() => setPresetMenuOpen((o) => !o)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<LayoutGrid className="size-4 mr-1" />
|
||||
<LayoutGrid data-icon="inline-start" />
|
||||
Preset
|
||||
</Button>
|
||||
{presetMenuOpen && (
|
||||
@@ -173,7 +173,7 @@ export function DashboardEditor({
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
|
||||
<RotateCcw className="size-4 mr-1" />
|
||||
<RotateCcw data-icon="inline-start" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
@@ -182,7 +182,7 @@ export function DashboardEditor({
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Plus className="size-4 mr-1" />
|
||||
<Plus data-icon="inline-start" />
|
||||
Add widget
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isPending}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { DashboardMeta } from "@/app/d/actions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
@@ -168,28 +169,32 @@ function DashboardKebab({
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
||||
<PenLine className="size-4 mr-2" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
{!dashboard.isDefault && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
|
||||
>
|
||||
<Star className="size-4 mr-2" />
|
||||
Set as default
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
||||
<PenLine />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!dashboard.isDefault && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
|
||||
>
|
||||
<Star />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
|
||||
>
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { setNotifChannel } from "@/app/settings/notify-actions";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type Channel = "push" | "inapp" | "ntfy";
|
||||
|
||||
@@ -46,12 +47,10 @@ export function NotifyChannelToggles({
|
||||
<span className="ml-2 text-xs text-muted-foreground">(NTFY_URL not configured)</span>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
checked={value}
|
||||
disabled={isPending || disabled}
|
||||
onChange={(e) => toggle(key, e.target.checked)}
|
||||
className="size-4 cursor-pointer"
|
||||
onCheckedChange={(checked) => toggle(key, checked)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
||||
if (status === "subscribed") {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
<span className="text-sm text-[var(--c-success)] dark:text-[var(--c-success)]">
|
||||
Push notifications enabled
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={sendTest} disabled={isPending}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Link, Check, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -32,7 +33,9 @@ export function ShareButton({
|
||||
setShareUrl(result.url);
|
||||
setOpen(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create share link");
|
||||
const message = err instanceof Error ? err.message : "Failed to create share link";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -41,6 +44,7 @@ export function ShareButton({
|
||||
if (!shareUrl) return;
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
@@ -49,7 +53,7 @@ export function ShareButton({
|
||||
<>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button variant="outline" onClick={share} disabled={isPending}>
|
||||
<Link />
|
||||
<Link data-icon="inline-start" />
|
||||
Share
|
||||
</Button>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -67,7 +71,7 @@ export function ShareButton({
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={shareUrl ?? ""} className="font-mono text-xs" />
|
||||
<Button variant="outline" size="icon" onClick={copyUrl} aria-label="Copy link">
|
||||
{copied ? <Check className="text-green-600" /> : <Copy />}
|
||||
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
+12
-17
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { getRegistry } from "@/modules/_core/registry";
|
||||
@@ -7,6 +8,7 @@ import { householdMembers, households, users } from "@/modules/_core/schema";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { NavIcon } from "@/components/nav-icon";
|
||||
import { NavLink } from "@/components/nav-link";
|
||||
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
||||
|
||||
type Variant = "side" | "top";
|
||||
|
||||
@@ -102,23 +104,16 @@ function HouseholdPill({
|
||||
return (
|
||||
<div className="household-pill" title={info.household.name}>
|
||||
<div style={{ display: "flex" }}>
|
||||
{visible.map((m, i) => {
|
||||
const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
|
||||
return (
|
||||
<span
|
||||
key={m.id}
|
||||
className="avatar avatar-sm"
|
||||
style={{
|
||||
background: avatarColor(m.id),
|
||||
marginLeft: i > 0 ? -6 : 0,
|
||||
boxShadow: "0 0 0 1.5px var(--card)",
|
||||
}}
|
||||
title={m.name ?? m.email ?? undefined}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{visible.map((m, i) => (
|
||||
<AvatarFallbackWithName
|
||||
key={m.id}
|
||||
name={m.name ?? m.email ?? undefined}
|
||||
image={m.image}
|
||||
className={cn(i > 0 && "-ml-1.5", "shadow-[0_0_0_1.5px_var(--card)]")}
|
||||
fallbackStyle={{ background: avatarColor(m.id) }}
|
||||
title={m.name ?? m.email ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="household-text" style={{ minWidth: 0 }}>
|
||||
<div
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -41,6 +42,11 @@ interface Props {
|
||||
signedIn?: boolean;
|
||||
}
|
||||
|
||||
const PALETTE_ITEMS = PALETTES.map((p) => ({ label: p.label, value: p.id }));
|
||||
const FONT_PAIR_ITEMS = FONT_PAIRS.map((f) => ({ label: f.label, value: f.id }));
|
||||
const DASH_LAYOUT_ITEMS = DASH_LAYOUTS.map((d) => ({ label: d.label, value: d.id }));
|
||||
const NAV_STYLE_ITEMS = NAV_STYLES.map((n) => ({ label: n.label, value: n.id }));
|
||||
|
||||
export function ThemePicker({
|
||||
initialPalette = "clay",
|
||||
initialMode = "system",
|
||||
@@ -68,7 +74,11 @@ export function ThemePicker({
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Theme</Label>
|
||||
<Select value={t.palette} onValueChange={(v) => t.setPalette(v as Palette)}>
|
||||
<Select
|
||||
items={PALETTE_ITEMS}
|
||||
value={t.palette}
|
||||
onValueChange={(v) => t.setPalette(v as Palette)}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
@@ -84,20 +94,22 @@ export function ThemePicker({
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PALETTES.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
<div className="flex items-center justify-between gap-6 w-40">
|
||||
<span>{p.label}</span>
|
||||
<span
|
||||
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
|
||||
borderColor: "var(--hair-2)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{PALETTES.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
<div className="flex items-center justify-between gap-6 w-40">
|
||||
<span>{p.label}</span>
|
||||
<span
|
||||
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
|
||||
borderColor: "var(--hair-2)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -121,16 +133,22 @@ export function ThemePicker({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type pairing</Label>
|
||||
<Select value={t.fontPair} onValueChange={(v) => t.setFontPair(v as FontPair)}>
|
||||
<Select
|
||||
items={FONT_PAIR_ITEMS}
|
||||
value={t.fontPair}
|
||||
onValueChange={(v) => t.setFontPair(v as FontPair)}
|
||||
>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_PAIRS.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{FONT_PAIRS.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -154,16 +172,22 @@ export function ThemePicker({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Dashboard layout</Label>
|
||||
<Select value={t.dashLayout} onValueChange={(v) => t.setDashLayout(v as DashLayout)}>
|
||||
<Select
|
||||
items={DASH_LAYOUT_ITEMS}
|
||||
value={t.dashLayout}
|
||||
onValueChange={(v) => t.setDashLayout(v as DashLayout)}
|
||||
>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DASH_LAYOUTS.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{DASH_LAYOUTS.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -187,16 +211,22 @@ export function ThemePicker({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Navigation</Label>
|
||||
<Select value={t.navStyle} onValueChange={(v) => t.setNavStyle(v as NavStyle)}>
|
||||
<Select
|
||||
items={NAV_STYLE_ITEMS}
|
||||
value={t.navStyle}
|
||||
onValueChange={(v) => t.setNavStyle(v as NavStyle)}
|
||||
>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NAV_STYLES.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
{n.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{NAV_STYLES.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
{n.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NotificationBell } from "@/components/notification-bell";
|
||||
import { TopbarSearch } from "@/components/topbar-search";
|
||||
import { TopbarTitle } from "@/components/topbar-title";
|
||||
import { TopbarNewButton } from "@/components/topbar-new-button";
|
||||
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
||||
|
||||
async function getNotifications(userId: string) {
|
||||
const rows = await db
|
||||
@@ -35,7 +36,6 @@ export async function Topbar() {
|
||||
? await getNotifications(userId)
|
||||
: { rows: [], unread: 0 };
|
||||
const userRow = userId ? await getUserAvatar(userId) : null;
|
||||
const initial = (userRow?.name ?? userRow?.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
|
||||
|
||||
return (
|
||||
<div className="topbar">
|
||||
@@ -56,22 +56,12 @@ export async function Topbar() {
|
||||
)}
|
||||
<TopbarNewButton />
|
||||
{userId && (
|
||||
<span
|
||||
className="avatar"
|
||||
style={{ width: 28, height: 28, fontSize: 12, background: "var(--c-household)" }}
|
||||
<AvatarFallbackWithName
|
||||
name={userRow?.name ?? userRow?.email ?? undefined}
|
||||
image={userRow?.image}
|
||||
size="sm"
|
||||
title={userRow?.name ?? userRow?.email ?? undefined}
|
||||
>
|
||||
{userRow?.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={userRow.image}
|
||||
alt=""
|
||||
style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</span>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,15 @@ export type ShareCapabilities = {
|
||||
defaultCapabilities?: string[];
|
||||
};
|
||||
|
||||
export type ShareContext = {
|
||||
householdId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type PublicShareContext = {
|
||||
householdId: string;
|
||||
};
|
||||
|
||||
export type ReminderCapabilities = {
|
||||
canRemind: boolean;
|
||||
};
|
||||
@@ -68,7 +77,8 @@ export type EntityTypeRegistration = {
|
||||
reminder?: ReminderCapabilities;
|
||||
search?: SearchAdapter;
|
||||
resolveUrl: (id: string) => string;
|
||||
loadForShare?: (id: string) => Promise<unknown>;
|
||||
canShareEntity?: (id: string, ctx: ShareContext) => Promise<boolean>;
|
||||
loadForShare?: (id: string, ctx: PublicShareContext) => Promise<unknown>;
|
||||
renderSharedView?: (props: {
|
||||
data: unknown;
|
||||
capabilities: { read: boolean; write: boolean };
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { EntityTypeRegistration, ShareContext } from "./module";
|
||||
|
||||
export async function ensureEntityShareAuthorized(
|
||||
registration: EntityTypeRegistration,
|
||||
entityId: string,
|
||||
ctx: ShareContext,
|
||||
): Promise<void> {
|
||||
if (!registration.canShareEntity) {
|
||||
throw new Error(`Entity type "${registration.type}" does not support share authorization`);
|
||||
}
|
||||
|
||||
const allowed = await registration.canShareEntity(entityId, ctx);
|
||||
if (!allowed) {
|
||||
throw new Error("You are not allowed to share this entity");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { db } from "@/lib/db";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { getEntityType } from "./registry";
|
||||
import { shareLinks } from "./schema";
|
||||
import { ensureEntityShareAuthorized } from "./share-authorization";
|
||||
|
||||
export type ShareLinkCapabilities = { read: boolean; write: boolean };
|
||||
|
||||
@@ -36,6 +37,10 @@ export async function createShareLink(
|
||||
}
|
||||
|
||||
const { user, household } = await getCurrentSession();
|
||||
await ensureEntityShareAuthorized(registration, entityId, {
|
||||
householdId: household.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const rawToken = randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashToken(rawToken);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { CalView } from "@/modules/_core/themes";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -48,6 +49,10 @@ type EventDraft = {
|
||||
type MobileView = "day" | "week" | "agenda";
|
||||
|
||||
const DEFAULT_COLOR = "#B85C3C";
|
||||
const VISIBILITY_ITEMS = [
|
||||
{ label: "Household", value: "household" },
|
||||
{ label: "Private", value: "private" },
|
||||
];
|
||||
|
||||
const VIEW_MAP: Record<CalView, string> = {
|
||||
month: "dayGridMonth",
|
||||
@@ -115,6 +120,10 @@ export function CalendarShell({
|
||||
() => eventRows.filter((event) => visibleIds.has(event.calendarId)),
|
||||
[eventRows, visibleIds],
|
||||
);
|
||||
const calendarSelectItems = useMemo(
|
||||
() => calendarRows.map((calendar) => ({ label: calendar.name, value: calendar.id })),
|
||||
[calendarRows],
|
||||
);
|
||||
|
||||
function toggleCalendar(id: string) {
|
||||
setVisibleIds((current) => {
|
||||
@@ -343,6 +352,7 @@ export function CalendarShell({
|
||||
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
items={VISIBILITY_ITEMS}
|
||||
value={calendar.visibility}
|
||||
onValueChange={(value) =>
|
||||
updateCalendar(calendar, { visibility: value as "private" | "household" })
|
||||
@@ -354,8 +364,13 @@ export function CalendarShell({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="household">Household</SelectItem>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectGroup>
|
||||
{VISIBILITY_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
@@ -391,6 +406,7 @@ export function CalendarShell({
|
||||
onChange={(event) => setCalendarColorValue(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
items={VISIBILITY_ITEMS}
|
||||
value={calendarVisibility}
|
||||
onValueChange={(value) =>
|
||||
setCalendarVisibilityValue(value as "private" | "household")
|
||||
@@ -402,8 +418,13 @@ export function CalendarShell({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="household">Household</SelectItem>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectGroup>
|
||||
{VISIBILITY_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -444,6 +465,7 @@ export function CalendarShell({
|
||||
/>
|
||||
<Label htmlFor="event-calendar">Calendar</Label>
|
||||
<Select
|
||||
items={calendarSelectItems}
|
||||
value={selectedEvent.calendarId}
|
||||
onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
|
||||
>
|
||||
@@ -454,11 +476,13 @@ export function CalendarShell({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{calendarRows.map((calendar) => (
|
||||
<SelectItem key={calendar.id} value={calendar.id}>
|
||||
{calendar.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{calendarSelectItems.map((calendar) => (
|
||||
<SelectItem key={calendar.value} value={calendar.value}>
|
||||
{calendar.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||
import { z } from "zod";
|
||||
import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries";
|
||||
import {
|
||||
canShareCalendar,
|
||||
canShareEvent,
|
||||
loadCalendarForShare,
|
||||
loadEventForShare,
|
||||
type CalendarShareData,
|
||||
@@ -150,7 +152,8 @@ const manifest: ModuleManifest = {
|
||||
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||
search: { search: searchCalendars },
|
||||
resolveUrl: (id) => `/calendar?id=${id}`,
|
||||
loadForShare: (id) => loadCalendarForShare(id),
|
||||
canShareEntity: canShareCalendar,
|
||||
loadForShare: loadCalendarForShare,
|
||||
renderSharedView: ({ data }) => <CalendarSharedView data={data as CalendarShareData} />,
|
||||
renderActivity: (entry) => {
|
||||
const name = entry.payload?.name as string | undefined;
|
||||
@@ -166,7 +169,8 @@ const manifest: ModuleManifest = {
|
||||
reminder: { canRemind: true },
|
||||
search: { search: searchEvents },
|
||||
resolveUrl: (id) => `/calendar/events/${id}`,
|
||||
loadForShare: (id) => loadEventForShare(id),
|
||||
canShareEntity: canShareEvent,
|
||||
loadForShare: loadEventForShare,
|
||||
renderSharedView: ({ data }) => <EventSharedView data={data as EventShareData} />,
|
||||
renderActivity: (entry) => {
|
||||
const title = entry.payload?.title as string | undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, asc, eq, gte, lte } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
|
||||
import { calendarEvents, calendars } from "../schema";
|
||||
|
||||
export type EventSummary = {
|
||||
@@ -21,11 +22,51 @@ export type CalendarShareData = {
|
||||
|
||||
export type EventShareData = EventSummary & { calendarName: string };
|
||||
|
||||
export async function loadCalendarForShare(id: string): Promise<CalendarShareData | null> {
|
||||
function canSeeCalendarRow(
|
||||
row: { householdId: string; ownerId: string; visibility: string },
|
||||
ctx: ShareContext,
|
||||
): boolean {
|
||||
if (row.householdId !== ctx.householdId) return false;
|
||||
return row.visibility === "household" || row.ownerId === ctx.userId;
|
||||
}
|
||||
|
||||
export async function canShareCalendar(id: string, ctx: ShareContext): Promise<boolean> {
|
||||
const [calendar] = await db
|
||||
.select({
|
||||
householdId: calendars.householdId,
|
||||
ownerId: calendars.ownerId,
|
||||
visibility: calendars.visibility,
|
||||
})
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, id))
|
||||
.limit(1);
|
||||
|
||||
return calendar ? canSeeCalendarRow(calendar, ctx) : false;
|
||||
}
|
||||
|
||||
export async function canShareEvent(id: string, ctx: ShareContext): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
householdId: calendars.householdId,
|
||||
ownerId: calendars.ownerId,
|
||||
visibility: calendars.visibility,
|
||||
})
|
||||
.from(calendarEvents)
|
||||
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||
.where(eq(calendarEvents.id, id))
|
||||
.limit(1);
|
||||
|
||||
return row ? canSeeCalendarRow(row, ctx) : false;
|
||||
}
|
||||
|
||||
export async function loadCalendarForShare(
|
||||
id: string,
|
||||
ctx: PublicShareContext,
|
||||
): Promise<CalendarShareData | null> {
|
||||
const [calendar] = await db
|
||||
.select({ id: calendars.id, name: calendars.name, color: calendars.color })
|
||||
.from(calendars)
|
||||
.where(eq(calendars.id, id))
|
||||
.where(and(eq(calendars.id, id), eq(calendars.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!calendar) return null;
|
||||
@@ -63,7 +104,10 @@ export async function loadCalendarForShare(id: string): Promise<CalendarShareDat
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEventForShare(id: string): Promise<EventShareData | null> {
|
||||
export async function loadEventForShare(
|
||||
id: string,
|
||||
ctx: PublicShareContext,
|
||||
): Promise<EventShareData | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: calendarEvents.id,
|
||||
@@ -77,7 +121,7 @@ export async function loadEventForShare(id: string): Promise<EventShareData | nu
|
||||
})
|
||||
.from(calendarEvents)
|
||||
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||
.where(eq(calendarEvents.id, id))
|
||||
.where(and(eq(calendarEvents.id, id), eq(calendars.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteCareSchedule,
|
||||
scheduleOnCalendar,
|
||||
@@ -35,7 +35,6 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
|
||||
const [calendarOpenId, setCalendarOpenId] = useState<string | null>(null);
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? "");
|
||||
const [reminderMinutes, setReminderMinutes] = useState("");
|
||||
const [calendarSuccess, setCalendarSuccess] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -56,6 +55,7 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
|
||||
router.refresh();
|
||||
} catch {
|
||||
setFormError("Failed to save schedule.");
|
||||
toast.error("Failed to save schedule");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -84,10 +84,11 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
|
||||
reminderMinutesBefore: reminderMinutes ? parseInt(reminderMinutes, 10) : undefined,
|
||||
});
|
||||
setCalendarOpenId(null);
|
||||
setCalendarSuccess(scheduleId);
|
||||
setTimeout(() => setCalendarSuccess(null), 4000);
|
||||
toast.success("Event added to calendar");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : "Failed to add to calendar.");
|
||||
const message = err instanceof Error ? err.message : "Failed to add to calendar.";
|
||||
setFormError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -189,7 +190,7 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
|
||||
title={s.enabled ? "Disable" : "Enable"}
|
||||
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
|
||||
s.enabled
|
||||
? "border-green-400 text-green-700 dark:text-green-400"
|
||||
? "border-[var(--c-success)] text-[var(--c-success)]"
|
||||
: "border-[var(--ink-faint)] text-[var(--ink-mute)]"
|
||||
}`}
|
||||
>
|
||||
@@ -256,15 +257,6 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calendarSuccess === s.id && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 ml-2">
|
||||
Event added to calendar.{" "}
|
||||
<Link href="/calendar" className="underline">
|
||||
View in calendar →
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { ShareButton } from "@/components/share-button";
|
||||
import { ShareLinkList } from "@/components/share-link-list";
|
||||
import type { EntityShareLink } from "@/modules/_core/share";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
addContainerImage,
|
||||
deleteContainer,
|
||||
@@ -32,9 +37,14 @@ export function ContainerDetail({ container, shareLinks }: Props) {
|
||||
|
||||
function handleDelete() {
|
||||
startTransition(async () => {
|
||||
await deleteContainer({ id: container.id });
|
||||
router.push("/garden");
|
||||
router.refresh();
|
||||
try {
|
||||
await deleteContainer({ id: container.id });
|
||||
toast.success("Container deleted");
|
||||
router.push("/garden");
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Failed to delete container");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,6 +63,7 @@ export function ContainerDetail({ container, shareLinks }: Props) {
|
||||
router.refresh();
|
||||
} catch {
|
||||
setGalleryError("Image upload failed.");
|
||||
toast.error("Image upload failed");
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
e.target.value = "";
|
||||
@@ -137,130 +148,136 @@ export function ContainerDetail({ container, shareLinks }: Props) {
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
|
||||
{(["info", "gallery"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`pb-2 text-sm font-medium capitalize transition-colors ${
|
||||
tab === t
|
||||
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-mute)] hover:text-[var(--ink)]"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="gallery">Gallery</TabsTrigger>
|
||||
</TabsList>
|
||||
<Separator />
|
||||
|
||||
{/* Info */}
|
||||
{tab === "info" && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
|
||||
<a
|
||||
href={`/garden/plants/new?containerId=${container.id}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
+ Add plant
|
||||
</a>
|
||||
<TabsContent value="info">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
|
||||
<a
|
||||
href={`/garden/plants/new?containerId=${container.id}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
+ Add plant
|
||||
</a>
|
||||
</div>
|
||||
{container.plants.length === 0 ? (
|
||||
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{container.plants.map((p) => (
|
||||
<a
|
||||
key={p.id}
|
||||
href={`/garden/plants/${p.id}`}
|
||||
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{p.primaryImageUrl && (
|
||||
<img
|
||||
src={p.primaryImageUrl}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{p.name}</p>
|
||||
{p.scientificName && (
|
||||
<p className="text-xs text-[var(--ink-mute)] italic">
|
||||
{p.scientificName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
p.healthStatus === "healthy"
|
||||
? "default"
|
||||
: p.healthStatus === "sick"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="ml-auto capitalize"
|
||||
>
|
||||
{p.healthStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{container.plants.length === 0 ? (
|
||||
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{container.plants.map((p) => (
|
||||
<a
|
||||
key={p.id}
|
||||
href={`/garden/plants/${p.id}`}
|
||||
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{p.primaryImageUrl && (
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gallery">
|
||||
<div className="flex flex-col gap-4">
|
||||
{container.images.length === 0 ? (
|
||||
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{uploadingImage && <Skeleton className="absolute inset-0 z-10 rounded-lg" />}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{container.images.map((url) => (
|
||||
<div key={url} className="relative group">
|
||||
<img
|
||||
src={p.primaryImageUrl}
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||
className="w-full aspect-square object-cover rounded-lg"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{p.name}</p>
|
||||
{p.scientificName && (
|
||||
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleSetPrimary(url)}
|
||||
disabled={isPending}
|
||||
title="Set as cover"
|
||||
className={`text-lg leading-none ${url === container.coverImageUrl ? "text-yellow-400" : "text-white"}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(url)}
|
||||
disabled={isPending}
|
||||
title="Remove"
|
||||
className="text-white text-lg leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{url === container.coverImageUrl && (
|
||||
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
|
||||
Cover
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-auto text-xs badge ${p.healthStatus === "healthy" ? "badge-success" : "badge-warning"}`}
|
||||
>
|
||||
{p.healthStatus}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{tab === "gallery" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{container.images.length === 0 ? (
|
||||
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{container.images.map((url) => (
|
||||
<div key={url} className="relative group">
|
||||
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleSetPrimary(url)}
|
||||
disabled={isPending}
|
||||
title="Set as cover"
|
||||
className={`text-lg leading-none ${url === container.coverImageUrl ? "text-yellow-400" : "text-white"}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(url)}
|
||||
disabled={isPending}
|
||||
title="Remove"
|
||||
className="text-white text-lg leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{url === container.coverImageUrl && (
|
||||
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
|
||||
Cover
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{container.images.length < 10 && (
|
||||
<label className="btn btn-ghost btn-sm cursor-pointer">
|
||||
{uploadingImage ? "Uploading…" : "Upload photo"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-[var(--ink-mute)]">
|
||||
{container.images.length}/10 photos
|
||||
</span>
|
||||
|
||||
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{container.images.length < 10 && (
|
||||
<label className="btn btn-ghost btn-sm cursor-pointer">
|
||||
{uploadingImage ? "Uploading…" : "Upload photo"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="text-xs text-[var(--ink-mute)]">
|
||||
{container.images.length}/10 photos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Container } from "lucide-react";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { ContainerForm } from "./container-form";
|
||||
import type { ContainerDto } from "../server/queries";
|
||||
|
||||
@@ -35,9 +43,15 @@ export function ContainerList({ containers }: Props) {
|
||||
)}
|
||||
|
||||
{containers.length === 0 && !showNew && (
|
||||
<p className="text-sm text-[var(--ink-mute)]">
|
||||
No containers yet. Add one to start organising your plants.
|
||||
</p>
|
||||
<Empty className="border-none">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Container />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No containers yet</EmptyTitle>
|
||||
<EmptyDescription>Add a container to start organising your plants.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
|
||||
import type { CalendarDto } from "../server/calendar-bridge";
|
||||
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
|
||||
import { ShareButton } from "@/components/share-button";
|
||||
import { ShareLinkList } from "@/components/share-link-list";
|
||||
import type { EntityShareLink } from "@/modules/_core/share";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CareHistoryList } from "./care-history-list";
|
||||
import { CareLogForm } from "./care-log-form";
|
||||
import { CareScheduleEditor } from "./care-schedule-editor";
|
||||
@@ -41,10 +46,10 @@ function InfoRow({
|
||||
);
|
||||
}
|
||||
|
||||
function healthBadgeClass(status: string): string {
|
||||
if (status === "healthy") return "badge-success";
|
||||
if (status === "sick") return "badge-danger";
|
||||
return "badge-warning";
|
||||
function healthBadgeVariant(status: string): "default" | "destructive" | "secondary" {
|
||||
if (status === "healthy") return "default";
|
||||
if (status === "sick") return "destructive";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLinks }: Props) {
|
||||
@@ -57,9 +62,14 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
|
||||
|
||||
function handleDelete() {
|
||||
startTransition(async () => {
|
||||
await deletePlant({ id: plant.id });
|
||||
router.push("/garden");
|
||||
router.refresh();
|
||||
try {
|
||||
await deletePlant({ id: plant.id });
|
||||
toast.success("Plant deleted");
|
||||
router.push("/garden");
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Failed to delete plant");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +88,7 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
|
||||
router.refresh();
|
||||
} catch {
|
||||
setGalleryError("Image upload failed.");
|
||||
toast.error("Image upload failed");
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
e.target.value = "";
|
||||
@@ -116,11 +127,13 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
|
||||
<p className="text-sm text-[var(--ink-mute)] italic mt-0.5">{plant.scientificName}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
|
||||
<Badge variant={healthBadgeVariant(plant.healthStatus)} className="capitalize">
|
||||
{plant.healthStatus}
|
||||
</span>
|
||||
</Badge>
|
||||
{plant.growthStage && (
|
||||
<span className="text-xs badge badge-outline capitalize">{plant.growthStage}</span>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{plant.growthStage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,135 +173,140 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
|
||||
{(["info", "gallery", "care"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`pb-2 text-sm font-medium capitalize transition-colors ${
|
||||
tab === t
|
||||
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-mute)]"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-0">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="gallery">Gallery</TabsTrigger>
|
||||
<TabsTrigger value="care">Care</TabsTrigger>
|
||||
</TabsList>
|
||||
<Separator />
|
||||
|
||||
{/* Info */}
|
||||
{tab === "info" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<InfoRow label="Category" value={plant.category} />
|
||||
{plant.containerName && (
|
||||
<InfoRow label="Container">
|
||||
<Link href={`/garden/containers/${plant.containerId}`} className="underline">
|
||||
{plant.containerName}
|
||||
</Link>
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow label="Acquired" value={plant.acquisitionDate} />
|
||||
<InfoRow label="Sunlight" value={plant.sunlight} />
|
||||
<InfoRow label="Watering" value={plant.wateringNotes} />
|
||||
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
|
||||
<InfoRow label="Notes" value={plant.notes} />
|
||||
{plant.recentCareLogs.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
|
||||
Recent care
|
||||
</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{plant.recentCareLogs.map((log) => (
|
||||
<li key={log.id} className="flex gap-2 flex-wrap">
|
||||
<span className="capitalize">{log.careType}</span>
|
||||
<span className="text-[var(--ink-mute)]">
|
||||
{new Date(log.performedAt).toLocaleDateString()}
|
||||
</span>
|
||||
{log.notes && <span className="text-[var(--ink-mute)]">— {log.notes}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{tab === "gallery" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{plant.images.length === 0 ? (
|
||||
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{plant.images.map((url) => (
|
||||
<div key={url} className="relative group">
|
||||
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleSetPrimary(url)}
|
||||
disabled={isPending}
|
||||
title="Set as primary"
|
||||
className={`text-lg leading-none ${url === plant.primaryImageUrl ? "text-yellow-400" : "text-white"}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(url)}
|
||||
disabled={isPending}
|
||||
title="Remove"
|
||||
className="text-white text-lg leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{url === plant.primaryImageUrl && (
|
||||
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{plant.images.length < 10 && (
|
||||
<label className="btn btn-ghost btn-sm cursor-pointer">
|
||||
{uploadingImage ? "Uploading…" : "Upload photo"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
</label>
|
||||
<TabsContent value="info">
|
||||
<div className="flex flex-col gap-2">
|
||||
<InfoRow label="Category" value={plant.category} />
|
||||
{plant.containerName && (
|
||||
<InfoRow label="Container">
|
||||
<Link href={`/garden/containers/${plant.containerId}`} className="underline">
|
||||
{plant.containerName}
|
||||
</Link>
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow label="Acquired" value={plant.acquisitionDate} />
|
||||
<InfoRow label="Sunlight" value={plant.sunlight} />
|
||||
<InfoRow label="Watering" value={plant.wateringNotes} />
|
||||
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
|
||||
<InfoRow label="Notes" value={plant.notes} />
|
||||
{plant.recentCareLogs.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
|
||||
Recent care
|
||||
</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{plant.recentCareLogs.map((log) => (
|
||||
<li key={log.id} className="flex gap-2 flex-wrap">
|
||||
<span className="capitalize">{log.careType}</span>
|
||||
<span className="text-[var(--ink-mute)]">
|
||||
{new Date(log.performedAt).toLocaleDateString()}
|
||||
</span>
|
||||
{log.notes && <span className="text-[var(--ink-mute)]">— {log.notes}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-[var(--ink-mute)]">{plant.images.length}/10 photos</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Care */}
|
||||
{tab === "care" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CareScheduleEditor plantId={plant.id} schedules={careSchedules} calendars={calendars} />
|
||||
<div className="border-t border-[var(--ink-faint)] pt-4">
|
||||
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
|
||||
Log care
|
||||
</p>
|
||||
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
|
||||
<TabsContent value="gallery">
|
||||
<div className="flex flex-col gap-4 relative">
|
||||
{plant.images.length === 0 ? (
|
||||
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{uploadingImage && <Skeleton className="absolute inset-0 z-10 rounded-lg" />}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{plant.images.map((url) => (
|
||||
<div key={url} className="relative group">
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-full aspect-square object-cover rounded-lg"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleSetPrimary(url)}
|
||||
disabled={isPending}
|
||||
title="Set as primary"
|
||||
className={`text-lg leading-none ${url === plant.primaryImageUrl ? "text-yellow-400" : "text-white"}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(url)}
|
||||
disabled={isPending}
|
||||
title="Remove"
|
||||
className="text-white text-lg leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{url === plant.primaryImageUrl && (
|
||||
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
|
||||
Primary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{plant.images.length < 10 && (
|
||||
<label className="btn btn-ghost btn-sm cursor-pointer">
|
||||
{uploadingImage ? "Uploading…" : "Upload photo"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<span className="text-xs text-[var(--ink-mute)]">
|
||||
{plant.images.length}/10 photos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-[var(--ink-faint)] pt-4">
|
||||
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
|
||||
History
|
||||
</p>
|
||||
<CareHistoryList logs={careLogs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="care">
|
||||
<div className="flex flex-col gap-6">
|
||||
<CareScheduleEditor
|
||||
plantId={plant.id}
|
||||
schedules={careSchedules}
|
||||
calendars={calendars}
|
||||
/>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
|
||||
Log care
|
||||
</p>
|
||||
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
|
||||
History
|
||||
</p>
|
||||
<CareHistoryList logs={careLogs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import type { PlantListItemDto } from "../server/queries";
|
||||
import { Sprout } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
plants: PlantListItemDto[];
|
||||
@@ -18,12 +26,18 @@ function daysAgo(isoString: string): string {
|
||||
export function PlantList({ plants }: Props) {
|
||||
if (plants.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>
|
||||
<Empty className="border-none py-8">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Sprout />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No plants yet</EmptyTitle>
|
||||
<EmptyDescription>Add your first plant to get started.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
|
||||
Add your first plant
|
||||
</Link>
|
||||
</div>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +110,7 @@ export function PlantList({ plants }: Props) {
|
||||
)}
|
||||
</div>
|
||||
{plant.hasOverdueCare && (
|
||||
<p className="text-xs text-amber-600 mt-0.5">Care overdue</p>
|
||||
<p className="text-xs text-[var(--warn)] mt-0.5">Care overdue</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,8 @@ const CARE_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function urgencyLabel(days: number): { text: string; cls: string } {
|
||||
if (days < 0)
|
||||
return { text: `${Math.abs(days)}d overdue`, cls: "text-red-600 dark:text-red-400" };
|
||||
if (days === 0) return { text: "due today", cls: "text-amber-600 dark:text-amber-400" };
|
||||
if (days < 0) return { text: `${Math.abs(days)}d overdue`, cls: "text-[var(--bad)]" };
|
||||
if (days === 0) return { text: "due today", cls: "text-[var(--warn)]" };
|
||||
return { text: `in ${days}d`, cls: "text-[var(--ink-mute)]" };
|
||||
}
|
||||
|
||||
@@ -108,7 +107,7 @@ export function GardenOverviewWidget({ stats }: { stats: GardenOverviewStats })
|
||||
</div>
|
||||
{stats.overdueCount > 0 && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl font-bold leading-none text-red-500">
|
||||
<span className="text-2xl font-bold leading-none text-[var(--bad)]">
|
||||
{stats.overdueCount}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--ink-mute)] mt-0.5">overdue</span>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { db } from "@/lib/db";
|
||||
import { registerItemToggleHook } from "../_core/registry";
|
||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||
import {
|
||||
canShareContainer,
|
||||
canSharePlant,
|
||||
loadContainerForShare,
|
||||
loadPlantForShare,
|
||||
type ContainerShareData,
|
||||
@@ -45,7 +47,8 @@ const gardenManifest: ModuleManifest = {
|
||||
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||
search: { search: searchPlants },
|
||||
resolveUrl: (id) => `/garden/plants/${id}`,
|
||||
loadForShare: (id) => loadPlantForShare(id),
|
||||
canShareEntity: canSharePlant,
|
||||
loadForShare: loadPlantForShare,
|
||||
renderSharedView: ({ data }) => {
|
||||
const d = data as PlantShareData;
|
||||
return (
|
||||
@@ -109,7 +112,8 @@ const gardenManifest: ModuleManifest = {
|
||||
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||
search: { search: searchContainers },
|
||||
resolveUrl: (id) => `/garden/containers/${id}`,
|
||||
loadForShare: (id) => loadContainerForShare(id),
|
||||
canShareEntity: canShareContainer,
|
||||
loadForShare: loadContainerForShare,
|
||||
renderSharedView: ({ data }) => {
|
||||
const d = data as ContainerShareData;
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
|
||||
import { gardenContainers, gardenPlants } from "../schema";
|
||||
|
||||
export type ContainerShareData = {
|
||||
@@ -27,7 +28,30 @@ export type PlantShareData = {
|
||||
sunlight: string | null;
|
||||
};
|
||||
|
||||
export async function loadPlantForShare(id: string): Promise<PlantShareData | null> {
|
||||
export async function canSharePlant(id: string, ctx: ShareContext): Promise<boolean> {
|
||||
const [plant] = await db
|
||||
.select({ id: gardenPlants.id })
|
||||
.from(gardenPlants)
|
||||
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
return !!plant;
|
||||
}
|
||||
|
||||
export async function canShareContainer(id: string, ctx: ShareContext): Promise<boolean> {
|
||||
const [container] = await db
|
||||
.select({ id: gardenContainers.id })
|
||||
.from(gardenContainers)
|
||||
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
return !!container;
|
||||
}
|
||||
|
||||
export async function loadPlantForShare(
|
||||
id: string,
|
||||
ctx: PublicShareContext,
|
||||
): Promise<PlantShareData | null> {
|
||||
const [plant] = await db
|
||||
.select({
|
||||
id: gardenPlants.id,
|
||||
@@ -44,18 +68,21 @@ export async function loadPlantForShare(id: string): Promise<PlantShareData | nu
|
||||
sunlight: gardenPlants.sunlight,
|
||||
})
|
||||
.from(gardenPlants)
|
||||
.where(eq(gardenPlants.id, id))
|
||||
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!plant) return null;
|
||||
return plant;
|
||||
}
|
||||
|
||||
export async function loadContainerForShare(id: string): Promise<ContainerShareData | null> {
|
||||
export async function loadContainerForShare(
|
||||
id: string,
|
||||
ctx: PublicShareContext,
|
||||
): Promise<ContainerShareData | null> {
|
||||
const [container] = await db
|
||||
.select()
|
||||
.from(gardenContainers)
|
||||
.where(eq(gardenContainers.id, id))
|
||||
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!container) return null;
|
||||
@@ -67,7 +94,7 @@ export async function loadContainerForShare(id: string): Promise<ContainerShareD
|
||||
scientificName: gardenPlants.scientificName,
|
||||
})
|
||||
.from(gardenPlants)
|
||||
.where(and(eq(gardenPlants.containerId, id)));
|
||||
.where(and(eq(gardenPlants.containerId, id), eq(gardenPlants.householdId, ctx.householdId)));
|
||||
|
||||
return {
|
||||
id: container.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||
import { z } from "zod";
|
||||
import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries";
|
||||
import { loadListForShare, type ListShareData } from "./server/share-queries";
|
||||
import { canShareList, loadListForShare, type ListShareData } from "./server/share-queries";
|
||||
import { ListSharedView } from "./components/shared-view";
|
||||
import { ListWidget } from "./components/list-widget";
|
||||
|
||||
@@ -34,7 +34,8 @@ const manifest: ModuleManifest = {
|
||||
share: { canShare: true, defaultCapabilities: ["read", "write"] },
|
||||
search: { search: searchLists },
|
||||
resolveUrl: (id) => `/lists/${id}`,
|
||||
loadForShare: (id) => loadListForShare(id),
|
||||
canShareEntity: canShareList,
|
||||
loadForShare: loadListForShare,
|
||||
renderSharedView: ({ data, capabilities, token }) => (
|
||||
<ListSharedView data={data as ListShareData} canWrite={capabilities.write} token={token} />
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
|
||||
import { listItems, lists } from "../schema";
|
||||
|
||||
export type ListShareItem = {
|
||||
@@ -19,7 +20,20 @@ export type ListShareData = {
|
||||
items: ListShareItem[];
|
||||
};
|
||||
|
||||
export async function loadListForShare(id: string): Promise<ListShareData | null> {
|
||||
export async function canShareList(id: string, ctx: ShareContext): Promise<boolean> {
|
||||
const [list] = await db
|
||||
.select({ id: lists.id })
|
||||
.from(lists)
|
||||
.where(and(eq(lists.id, id), eq(lists.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
return !!list;
|
||||
}
|
||||
|
||||
export async function loadListForShare(
|
||||
id: string,
|
||||
ctx: PublicShareContext,
|
||||
): Promise<ListShareData | null> {
|
||||
const [list] = await db
|
||||
.select({
|
||||
id: lists.id,
|
||||
@@ -28,7 +42,7 @@ export async function loadListForShare(id: string): Promise<ListShareData | null
|
||||
householdId: lists.householdId,
|
||||
})
|
||||
.from(lists)
|
||||
.where(eq(lists.id, id))
|
||||
.where(and(eq(lists.id, id), eq(lists.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!list) return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||
import { z } from "zod";
|
||||
import { listWidgetNotes, searchNotes } from "./server/queries";
|
||||
import { loadNoteForShare, type NoteShareData } from "./server/share-queries";
|
||||
import { canShareNote, loadNoteForShare, type NoteShareData } from "./server/share-queries";
|
||||
import { NoteSharedView } from "./components/shared-view";
|
||||
|
||||
const notesWidgetConfigSchema = z.object({
|
||||
@@ -72,7 +72,8 @@ const manifest: ModuleManifest = {
|
||||
reminder: { canRemind: true },
|
||||
search: { search: searchNotes },
|
||||
resolveUrl: (id) => `/notes/${id}`,
|
||||
loadForShare: (id) => loadNoteForShare(id),
|
||||
canShareEntity: canShareNote,
|
||||
loadForShare: loadNoteForShare,
|
||||
renderSharedView: ({ data }) => <NoteSharedView data={data as NoteShareData} />,
|
||||
renderActivity: (entry) => {
|
||||
const title = entry.payload?.title as string | undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
|
||||
import { notes } from "../schema";
|
||||
|
||||
export type NoteShareData = {
|
||||
@@ -10,7 +11,20 @@ export type NoteShareData = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function loadNoteForShare(id: string): Promise<NoteShareData | null> {
|
||||
export async function canShareNote(id: string, ctx: ShareContext): Promise<boolean> {
|
||||
const [note] = await db
|
||||
.select({ id: notes.id })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
return !!note;
|
||||
}
|
||||
|
||||
export async function loadNoteForShare(
|
||||
id: string,
|
||||
ctx: PublicShareContext,
|
||||
): Promise<NoteShareData | null> {
|
||||
const [note] = await db
|
||||
.select({
|
||||
id: notes.id,
|
||||
@@ -20,7 +34,7 @@ export async function loadNoteForShare(id: string): Promise<NoteShareData | null
|
||||
updatedAt: notes.updatedAt,
|
||||
})
|
||||
.from(notes)
|
||||
.where(eq(notes.id, id))
|
||||
.where(and(eq(notes.id, id), eq(notes.householdId, ctx.householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!note) return null;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user