Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090366422d | ||
|
|
4049a73c6b | ||
|
|
272d3cbbe6 | ||
|
|
b5a966e3a9 | ||
|
|
48063e4b7f | ||
|
|
6eab68560e | ||
|
|
7d0f02996f | ||
|
|
32e97b0677 | ||
|
|
e0b423aad5 | ||
|
|
389ff6040b | ||
|
|
65d36bab4e | ||
|
|
298e6b868d |
+3
-2
@@ -40,5 +40,6 @@ MINIO_ROOT_USER=famapp
|
||||
MINIO_ROOT_PASSWORD=changeme
|
||||
MINIO_BUCKET=garden
|
||||
|
||||
# Perenual plant species API (https://perenual.com — free tier available)
|
||||
PERENUAL_API_KEY=
|
||||
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
|
||||
OPENPLANTBOOK_CLIENT_ID=
|
||||
OPENPLANTBOOK_CLIENT_SECRET=
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -28,10 +28,32 @@ jobs:
|
||||
|
||||
- run: pnpm format:check
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.3
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .next/cache
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||
${{ runner.os }}-nextjs-
|
||||
|
||||
- run: pnpm build
|
||||
env:
|
||||
# Build only — no DB connection. Provide a placeholder so any
|
||||
# module-level reads of DATABASE_URL don't crash the build.
|
||||
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
|
||||
AUTH_SECRET: ci-placeholder-secret
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [0.4.6](https://github.com/ginnoir/famapp/compare/v0.4.5...v0.4.6) (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **garden:** accept relative urls for plant image uploads ([6ca2e52](https://github.com/ginnoir/famapp/commit/6ca2e52e0a63d94826d9b1e208bcdded9c75fb07))
|
||||
|
||||
## [0.4.5](https://github.com/ginnoir/famapp/compare/v0.4.4...v0.4.5) (2026-06-02)
|
||||
|
||||
### Features
|
||||
|
||||
- **bangs:** add bang counter dashboard widget ([c850521](https://github.com/ginnoir/famapp/commit/c850521f3e993a5410f37cf29eb635cbc9540fd1))
|
||||
|
||||
## [0.4.4](https://github.com/ginnoir/famapp/compare/v0.4.3...v0.4.4) (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **garden:** remove trailing slashes from openplantbook api urls and update label ([c744921](https://github.com/ginnoir/famapp/commit/c744921e6cfcf0269992cfd9afea47cc55082602))
|
||||
|
||||
## [0.4.3](https://github.com/ginnoir/famapp/compare/v0.4.2...v0.4.3) (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **garden:** replace perenual with openplantbook for species lookup ([8722c20](https://github.com/ginnoir/famapp/commit/8722c205e6257a0590b48d0a858ad71d56ab07ac))
|
||||
|
||||
All notable changes to famapp are documented here.
|
||||
|
||||
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ ENV CI=true
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY . .
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
|
||||
pnpm build
|
||||
|
||||
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "bang_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"recorded_by" uuid,
|
||||
"occurred_on" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "bang_events" ADD CONSTRAINT "bang_events_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "bang_events" ADD CONSTRAINT "bang_events_recorded_by_users_id_fk" FOREIGN KEY ("recorded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "bang_events_household_occurred_idx" ON "bang_events" USING btree ("household_id","occurred_on");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "fc208bad-8bfd-452d-876f-7e853f699931",
|
||||
"prevId": "cb06cd57-d5a0-4fb7-bbf3-335173a1168c",
|
||||
"id": "ff1f5ff1-7539-4c5e-a20d-f01c719ef198",
|
||||
"prevId": "fc208bad-8bfd-452d-876f-7e853f699931",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,20 @@
|
||||
"when": 1748736000000,
|
||||
"tag": "0015_garden_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1748822400000,
|
||||
"tag": "0016_garden_improvements",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1780391596552,
|
||||
"tag": "0017_bang_counter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "famapp",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.3",
|
||||
@@ -48,15 +48,14 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@release-it/conventional-changelog": "^11.0.1",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-grid-layout": "^2.1.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
@@ -81,6 +80,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -98,7 +98,6 @@
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.4.3",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+16
-24
@@ -29,6 +29,9 @@ importers:
|
||||
'@fullcalendar/timegrid':
|
||||
specifier: ^6.1.20
|
||||
version: 6.1.20(@fullcalendar/core@6.1.20)
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -83,9 +86,6 @@ importers:
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
zod-to-json-schema:
|
||||
specifier: ^3.25.2
|
||||
version: 3.25.2(zod@4.4.3)
|
||||
devDependencies:
|
||||
'@commitlint/cli':
|
||||
specifier: ^21.0.2
|
||||
@@ -93,9 +93,6 @@ importers:
|
||||
'@commitlint/config-conventional':
|
||||
specifier: ^21.0.2
|
||||
version: 21.0.2
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.5
|
||||
version: 3.3.5
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(eslint@9.39.4(jiti@2.7.0))
|
||||
@@ -108,6 +105,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.2.4
|
||||
version: 4.2.4
|
||||
'@types/canvas-confetti':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
'@types/node':
|
||||
specifier: ^22.9.0
|
||||
version: 22.19.17
|
||||
@@ -117,9 +117,6 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/react-grid-layout':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@types/web-push':
|
||||
specifier: ^3.6.4
|
||||
version: 3.6.4
|
||||
@@ -1853,6 +1850,9 @@ packages:
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||
|
||||
'@types/canvas-confetti@1.9.0':
|
||||
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -1877,10 +1877,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/react-grid-layout@2.1.0':
|
||||
resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==}
|
||||
deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
@@ -2293,6 +2289,9 @@ packages:
|
||||
caniuse-lite@1.0.30001791:
|
||||
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
|
||||
|
||||
canvas-confetti@1.9.4:
|
||||
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6544,6 +6543,8 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/canvas-confetti@1.9.0': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -6564,13 +6565,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react-grid-layout@2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
react-grid-layout: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
'@types/react@19.2.14':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
@@ -6997,6 +6991,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001791: {}
|
||||
|
||||
canvas-confetti@1.9.4: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -10093,10 +10089,6 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@4.4.3):
|
||||
dependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.4.3):
|
||||
dependencies:
|
||||
zod: 4.4.3
|
||||
|
||||
@@ -1346,3 +1346,81 @@ select {
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 2px rgba(244, 239, 229, 0.08);
|
||||
}
|
||||
|
||||
/* ─── Bang Counter celebration animations ─────────────────────────────────── */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10% {
|
||||
transform: translateX(-6px) rotate(-1deg);
|
||||
}
|
||||
20% {
|
||||
transform: translateX(6px) rotate(1deg);
|
||||
}
|
||||
30% {
|
||||
transform: translateX(-5px) rotate(-0.8deg);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(5px) rotate(0.8deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-4px) rotate(-0.5deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(4px) rotate(0.5deg);
|
||||
}
|
||||
70% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes countPop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.45);
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiFloat {
|
||||
0% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-60px) scale(1.2);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-120px) scale(0.7);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.35s ease-in-out both;
|
||||
}
|
||||
.animate-count-pop {
|
||||
animation: countPop 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
}
|
||||
.animate-emoji-float {
|
||||
animation: emojiFloat 1.8s ease-out both;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { getRegistry } from "@/modules/_core/registry";
|
||||
import { NavLink } from "@/components/nav-link";
|
||||
|
||||
const ITEMS: Array<{ href: string; label: string; icon: string }> = [
|
||||
{ href: "/", label: "Dashboard", icon: "home" },
|
||||
{ href: "/calendar", label: "Calendar", icon: "calendar" },
|
||||
{ href: "/lists", label: "Lists", icon: "list" },
|
||||
{ href: "/notes", label: "Notes", icon: "note" },
|
||||
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
export async function BottomNav() {
|
||||
const { modules } = getRegistry();
|
||||
const moduleNav = modules.flatMap((m) => (m.nav ? [m.nav] : []));
|
||||
|
||||
const items = [
|
||||
{ href: "/", label: "Dashboard", icon: "home" },
|
||||
...moduleNav.map((n) => ({ href: n.href, label: n.label, icon: n.icon ?? "circle" })),
|
||||
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
export function BottomNav() {
|
||||
return (
|
||||
<nav className="bottom-nav" aria-label="Primary navigation">
|
||||
{ITEMS.map((it) => (
|
||||
{items.map((it) => (
|
||||
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import confetti from "canvas-confetti";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { BangStatsDto } from "../server/queries";
|
||||
import { addBang } from "../server/actions";
|
||||
|
||||
// ─── Celebration engine ───────────────────────────────────────────────────────
|
||||
|
||||
function launchFirework(originX: number, originY: number) {
|
||||
confetti({
|
||||
particleCount: 80,
|
||||
startVelocity: 45,
|
||||
spread: 360,
|
||||
origin: { x: originX, y: originY },
|
||||
colors: ["#ff0", "#f0f", "#0ff", "#f60", "#0f6", "#06f"],
|
||||
shapes: ["circle", "square"],
|
||||
gravity: 0.8,
|
||||
scalar: 1.2,
|
||||
ticks: 200,
|
||||
});
|
||||
}
|
||||
|
||||
function launchSideCannon(side: "left" | "right") {
|
||||
confetti({
|
||||
particleCount: 120,
|
||||
angle: side === "left" ? 60 : 120,
|
||||
spread: 55,
|
||||
origin: { x: side === "left" ? 0 : 1, y: 0.65 },
|
||||
colors: ["#ff4e50", "#fc913a", "#f9d423", "#ede574", "#e1f5c4"],
|
||||
shapes: ["circle", "square", "star"],
|
||||
scalar: 1.1,
|
||||
ticks: 300,
|
||||
});
|
||||
}
|
||||
|
||||
function launchStarBurst() {
|
||||
const defaults = {
|
||||
spread: 360,
|
||||
ticks: 100,
|
||||
gravity: 0,
|
||||
decay: 0.94,
|
||||
startVelocity: 30,
|
||||
shapes: ["star"] as confetti.Shape[],
|
||||
colors: ["FFE400", "FFBD00", "E89400", "FFCA6C", "FDFFB8"],
|
||||
};
|
||||
function shoot() {
|
||||
confetti({ ...defaults, particleCount: 40, scalar: 1.2, shapes: ["star"] });
|
||||
confetti({ ...defaults, particleCount: 15, scalar: 0.75, shapes: ["circle"] });
|
||||
}
|
||||
shoot();
|
||||
setTimeout(shoot, 100);
|
||||
setTimeout(shoot, 200);
|
||||
}
|
||||
|
||||
function synthFireworkSound() {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
// Rising "pew" tone
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.setValueAtTime(200, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.12);
|
||||
gain.gain.setValueAtTime(0.35, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.18);
|
||||
|
||||
// Noise "boom"
|
||||
const bufferSize = ctx.sampleRate * 0.4;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
|
||||
const noise = ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
const noiseGain = ctx.createGain();
|
||||
const bpFilter = ctx.createBiquadFilter();
|
||||
bpFilter.type = "bandpass";
|
||||
bpFilter.frequency.value = 150;
|
||||
noise.connect(bpFilter);
|
||||
bpFilter.connect(noiseGain);
|
||||
noiseGain.connect(ctx.destination);
|
||||
noiseGain.gain.setValueAtTime(0.6, ctx.currentTime + 0.15);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
|
||||
noise.start(ctx.currentTime + 0.15);
|
||||
noise.stop(ctx.currentTime + 0.6);
|
||||
} catch {
|
||||
// Autoplay policy blocked — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
type EmojiParticle = {
|
||||
id: number;
|
||||
emoji: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
const CELEBRATION_EMOJIS = [
|
||||
"💥",
|
||||
"🎉",
|
||||
"🔥",
|
||||
"✨",
|
||||
"🎊",
|
||||
"🥳",
|
||||
"💦",
|
||||
"😈",
|
||||
"🎆",
|
||||
"⭐",
|
||||
"🍆",
|
||||
"💫",
|
||||
"🌟",
|
||||
"🎇",
|
||||
"🍒",
|
||||
];
|
||||
|
||||
function EmojiOverlay({ particles }: { particles: EmojiParticle[] }) {
|
||||
if (particles.length === 0) return null;
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[9999] overflow-hidden" aria-hidden>
|
||||
{particles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="absolute select-none animate-emoji-float"
|
||||
style={{
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
fontSize: `${p.size}rem`,
|
||||
animationDelay: `${p.delay}ms`,
|
||||
}}
|
||||
>
|
||||
{p.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
type Props = {
|
||||
stats: BangStatsDto;
|
||||
maxRecentBangs: number;
|
||||
};
|
||||
|
||||
export function BangWidget({ stats, maxRecentBangs }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dateValue, setDateValue] = useState(todayValue());
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emojiParticles, setEmojiParticles] = useState<EmojiParticle[]>([]);
|
||||
const [countPop, setCountPop] = useState(false);
|
||||
const [shake, setShake] = useState(false);
|
||||
const [flash, setFlash] = useState(false);
|
||||
const particleIdRef = useRef(0);
|
||||
|
||||
const spawnEmojis = useCallback(() => {
|
||||
const batch: EmojiParticle[] = Array.from({ length: 50 }, () => ({
|
||||
id: ++particleIdRef.current,
|
||||
emoji: CELEBRATION_EMOJIS[Math.floor(Math.random() * CELEBRATION_EMOJIS.length)]!,
|
||||
x: Math.random() * 100,
|
||||
y: 20 + Math.random() * 70, // keep out of very top/bottom edges so float-up is visible
|
||||
size: 1.5 + Math.random() * 2,
|
||||
delay: Math.random() * 600, // stagger spawning over 600ms so it feels like a cascade
|
||||
}));
|
||||
setEmojiParticles((prev) => [...prev, ...batch]);
|
||||
setTimeout(() => {
|
||||
const ids = new Set(batch.map((p) => p.id));
|
||||
setEmojiParticles((prev) => prev.filter((p) => !ids.has(p.id)));
|
||||
}, 2400);
|
||||
}, []);
|
||||
|
||||
const triggerCelebration = useCallback(() => {
|
||||
setFlash(true);
|
||||
setTimeout(() => setFlash(false), 120);
|
||||
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 350);
|
||||
|
||||
setCountPop(true);
|
||||
setTimeout(() => setCountPop(false), 400);
|
||||
|
||||
synthFireworkSound();
|
||||
spawnEmojis();
|
||||
launchStarBurst();
|
||||
|
||||
launchSideCannon("left");
|
||||
setTimeout(() => launchSideCannon("right"), 150);
|
||||
|
||||
setTimeout(() => launchFirework(0.3, 0.7), 200);
|
||||
setTimeout(() => launchFirework(0.7, 0.65), 450);
|
||||
setTimeout(() => launchFirework(0.5, 0.6), 700);
|
||||
setTimeout(() => launchFirework(0.2, 0.75), 950);
|
||||
setTimeout(() => launchFirework(0.8, 0.7), 1100);
|
||||
setTimeout(() => {
|
||||
launchSideCannon("left");
|
||||
launchSideCannon("right");
|
||||
}, 1300);
|
||||
setTimeout(() => launchFirework(0.5, 0.5), 1600);
|
||||
}, [spawnEmojis]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await addBang({ occurredOn: dateValue });
|
||||
setOpen(false);
|
||||
triggerCelebration();
|
||||
} catch {
|
||||
setError("Something went wrong. Try again.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative flex flex-col gap-3 overflow-hidden ${shake ? "animate-shake" : ""}`}>
|
||||
{flash && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 rounded-xl bg-white/70" />
|
||||
)}
|
||||
|
||||
<EmojiOverlay particles={emojiParticles} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={`text-5xl font-bold leading-none tabular-nums ${countPop ? "animate-count-pop" : ""}`}
|
||||
>
|
||||
{stats.total}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--ink-mute)] mt-1">
|
||||
{stats.total === 1 ? "bang" : "bangs"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary flex items-center gap-1.5 text-sm px-3 py-1.5"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span aria-hidden>💥</span> Add Bang
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record a bang</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form id="bang-form" onSubmit={handleSubmit} className="flex flex-col gap-3 pt-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="bang-date" className="text-sm font-medium">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
id="bang-date"
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={(e) => setDateValue(e.target.value)}
|
||||
className="input input-sm"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-mute)]">
|
||||
Change if you're documenting a bang from a previous day.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</form>
|
||||
<DialogFooter showCloseButton>
|
||||
<button
|
||||
type="submit"
|
||||
form="bang-form"
|
||||
disabled={isPending}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
{isPending ? "Recording…" : "🎆 Record it"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{stats.recent.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-[var(--surface-3)] pt-2">
|
||||
<p className="text-xs font-medium text-[var(--ink-mute)] mb-1">
|
||||
Last {Math.min(stats.recent.length, maxRecentBangs)}
|
||||
</p>
|
||||
{stats.recent.map((bang) => (
|
||||
<div key={bang.id} className="flex items-center justify-between gap-2 py-0.5">
|
||||
<span className="text-sm">{formatBangDate(bang.occurredOn)}</span>
|
||||
{bang.recordedByName && (
|
||||
<span className="max-w-[40%] truncate text-xs text-[var(--ink-mute)]">
|
||||
{bang.recordedByName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.total === 0 && (
|
||||
<p className="text-sm text-[var(--ink-mute)]">No bangs yet. Add the first one!</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function todayValue(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatBangDate(iso: string): string {
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
const d = new Date(year!, month! - 1, day!);
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
import type { ModuleManifest, WidgetContext } from "../_core/module";
|
||||
import { getBangStats } from "./server/queries";
|
||||
import { BangWidget } from "./components/bang-widget";
|
||||
|
||||
const bangWidgetConfigSchema = z.object({
|
||||
maxRecentBangs: z.number().int().min(1).max(20).default(5),
|
||||
});
|
||||
|
||||
async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
|
||||
const parsed = bangWidgetConfigSchema.parse(config);
|
||||
const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs);
|
||||
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
|
||||
}
|
||||
|
||||
const bangsManifest: ModuleManifest = {
|
||||
id: "bangs",
|
||||
name: "Bangs",
|
||||
entities: [
|
||||
{
|
||||
type: "bangs.event",
|
||||
label: { singular: "Bang", plural: "Bangs" },
|
||||
resolveUrl: () => "/",
|
||||
renderActivity: (entry) => {
|
||||
const date = entry.payload?.occurredOn as string | undefined;
|
||||
return date ? `Recorded a bang on ${date}` : "Recorded a bang";
|
||||
},
|
||||
},
|
||||
],
|
||||
dashboardWidgets: [
|
||||
{
|
||||
id: "bangs.counter",
|
||||
title: "Bang Counter",
|
||||
description: "Track and celebrate bangs with your household.",
|
||||
category: "Fun",
|
||||
defaultSize: { w: 3, h: 3 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
defaultPriority: 60,
|
||||
configSchema: bangWidgetConfigSchema,
|
||||
defaultConfig: { maxRecentBangs: 5 },
|
||||
render: (props) => <BangWidgetServer {...props} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default bangsManifest;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { households, users } from "../_core/schema";
|
||||
|
||||
export const bangEvents = pgTable(
|
||||
"bang_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
recordedBy: uuid("recorded_by").references(() => users.id, { onDelete: "set null" }),
|
||||
occurredOn: text("occurred_on").notNull(), // YYYY-MM-DD, text to avoid timezone confusion
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index("bang_events_household_occurred_idx").on(t.householdId, t.occurredOn)],
|
||||
);
|
||||
|
||||
export type BangEvent = typeof bangEvents.$inferSelect;
|
||||
@@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { logActivity } from "@/modules/_core/activity";
|
||||
import { bangEvents } from "../schema";
|
||||
|
||||
const addBangInput = z.object({
|
||||
occurredOn: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function todayString(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export async function addBang(input: z.input<typeof addBangInput> = {}) {
|
||||
const parsed = addBangInput.parse(input);
|
||||
const { user, household } = await getCurrentSession();
|
||||
|
||||
const occurredOn = parsed.occurredOn ?? todayString();
|
||||
|
||||
const [bang] = await db
|
||||
.insert(bangEvents)
|
||||
.values({
|
||||
householdId: household.id,
|
||||
recordedBy: user.id,
|
||||
occurredOn,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!bang) throw new Error("Bang was not recorded");
|
||||
|
||||
await logActivity({
|
||||
entityType: "bangs.event",
|
||||
entityId: bang.id,
|
||||
action: "create",
|
||||
payload: { occurredOn },
|
||||
});
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/d/[slug]", "page");
|
||||
|
||||
return bang;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { bangEvents } from "../schema";
|
||||
|
||||
export type BangStatsDto = {
|
||||
total: number;
|
||||
recent: RecentBangDto[];
|
||||
};
|
||||
|
||||
export type RecentBangDto = {
|
||||
id: string;
|
||||
occurredOn: string;
|
||||
recordedByName: string | null;
|
||||
};
|
||||
|
||||
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
|
||||
const [totalRow] = await db
|
||||
.select({ total: count() })
|
||||
.from(bangEvents)
|
||||
.where(eq(bangEvents.householdId, householdId));
|
||||
|
||||
const recent = await db
|
||||
.select({
|
||||
id: bangEvents.id,
|
||||
occurredOn: bangEvents.occurredOn,
|
||||
recordedByName: users.name,
|
||||
})
|
||||
.from(bangEvents)
|
||||
.leftJoin(users, eq(bangEvents.recordedBy, users.id))
|
||||
.where(eq(bangEvents.householdId, householdId))
|
||||
.orderBy(desc(bangEvents.occurredOn), desc(bangEvents.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return {
|
||||
total: totalRow?.total ?? 0,
|
||||
recent,
|
||||
};
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export function PlantForm({ existingPlant, defaultContainerId, containers }: Pro
|
||||
initialValue={existingPlant?.scientificName ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-[var(--ink-mute)]">
|
||||
Search Perenual to auto-fill scientific name and care notes.
|
||||
Search OpenPlantBook to auto-fill scientific name and care notes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,8 +118,8 @@ const plantInput = z.object({
|
||||
fertilizingNotes: z.string().trim().max(2000).nullable().optional(),
|
||||
notes: z.string().trim().max(2000).nullable().optional(),
|
||||
acquisitionDate: z.string().nullable().optional(),
|
||||
images: z.array(z.string().url()).max(10).default([]),
|
||||
primaryImageUrl: z.string().url().nullable().optional(),
|
||||
images: z.array(z.string().min(1)).max(10).default([]),
|
||||
primaryImageUrl: z.string().min(1).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function createPlant(input: z.input<typeof plantInput>) {
|
||||
@@ -230,7 +230,7 @@ export async function deletePlant(input: { id: string }) {
|
||||
}
|
||||
|
||||
export async function addPlantImage(input: { id: string; url: string }) {
|
||||
const parsed = z.object({ id: z.string().uuid(), url: z.string().url() }).parse(input);
|
||||
const parsed = z.object({ id: z.string().uuid(), url: z.string().min(1) }).parse(input);
|
||||
const { household } = await getCurrentSession();
|
||||
await assertCanAccessPlant(parsed.id, household.id);
|
||||
|
||||
|
||||
@@ -13,60 +13,89 @@ export type SpeciesSuggestion = {
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const BASE_URL = "https://perenual.com";
|
||||
const BASE_URL = "https://open.plantbook.io/api/v1";
|
||||
|
||||
type TokenCache = { access_token: string; expiresAt: number } | null;
|
||||
let tokenCache: TokenCache = null;
|
||||
|
||||
async function getToken(): Promise<string | null> {
|
||||
const clientId = process.env.OPENPLANTBOOK_CLIENT_ID;
|
||||
const clientSecret = process.env.OPENPLANTBOOK_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
|
||||
return tokenCache.access_token;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/token/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
|
||||
const json = (await res.json()) as { access_token: string; expires_in: number };
|
||||
tokenCache = {
|
||||
access_token: json.access_token,
|
||||
expiresAt: Date.now() + json.expires_in * 1000,
|
||||
};
|
||||
return tokenCache.access_token;
|
||||
} catch (err) {
|
||||
console.error("[species-lookup] getToken failed", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSunlight(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
if (typeof value === "string" && value) return value.split(", ");
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseItem(item: Record<string, unknown>): SpeciesSuggestion {
|
||||
const rawNames = item.scientific_name;
|
||||
const scientificName = Array.isArray(rawNames) && rawNames.length > 0 ? String(rawNames[0]) : "";
|
||||
|
||||
const sunlight = Array.isArray(item.sunlight) ? item.sunlight.map(String) : [];
|
||||
|
||||
const defaultImage = item.default_image as Record<string, unknown> | null | undefined;
|
||||
const rawImageUrl = defaultImage?.regular_url ?? defaultImage?.medium_url ?? null;
|
||||
|
||||
return {
|
||||
id: String(item.id),
|
||||
common_name: String(item.common_name ?? ""),
|
||||
scientific_name: scientificName,
|
||||
id: String(item.pid ?? ""),
|
||||
common_name: String(item.alias ?? ""),
|
||||
scientific_name: String(item.display_pid ?? ""),
|
||||
watering: String(item.watering ?? ""),
|
||||
sunlight,
|
||||
cycle: String(item.cycle ?? ""),
|
||||
default_image_url: rawImageUrl ? String(rawImageUrl) : null,
|
||||
sunlight: parseSunlight(item.sunlight),
|
||||
cycle: "",
|
||||
default_image_url: item.image_url ? String(item.image_url) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
|
||||
const apiKey = process.env.PERENUAL_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
if (!query.trim()) return [];
|
||||
const token = await getToken();
|
||||
if (!token) return [];
|
||||
|
||||
try {
|
||||
const url = `${BASE_URL}/api/species-list?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Perenual API error: ${res.status}`);
|
||||
const url = `${BASE_URL}/plant/search?alias=${encodeURIComponent(query)}&limit=20`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`OpenPlantBook search error: ${res.status} ${res.url}`);
|
||||
|
||||
const json = (await res.json()) as { data?: unknown[] };
|
||||
if (!Array.isArray(json.data)) return [];
|
||||
const json = (await res.json()) as { results?: unknown[] };
|
||||
if (!Array.isArray(json.results)) return [];
|
||||
|
||||
const results = json.data.map((item) => parseItem(item as Record<string, unknown>));
|
||||
const pids = json.results
|
||||
.map((r) => String((r as Record<string, unknown>).pid ?? ""))
|
||||
.filter(Boolean);
|
||||
|
||||
await Promise.allSettled(
|
||||
results.map((r) =>
|
||||
db
|
||||
.insert(gardenSpeciesCache)
|
||||
.values({
|
||||
speciesId: r.id,
|
||||
data: r as unknown as Record<string, unknown>,
|
||||
cachedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: gardenSpeciesCache.speciesId,
|
||||
set: { data: r as unknown as Record<string, unknown>, cachedAt: new Date() },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return results;
|
||||
const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid)));
|
||||
return details
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<SpeciesSuggestion> =>
|
||||
r.status === "fulfilled" && r.value !== null,
|
||||
)
|
||||
.map((r) => r.value);
|
||||
} catch (err) {
|
||||
console.error("[species-lookup] searchSpecies failed", err);
|
||||
return [];
|
||||
@@ -83,12 +112,15 @@ export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | nu
|
||||
|
||||
if (cached) return cached.data as unknown as SpeciesSuggestion;
|
||||
|
||||
const apiKey = process.env.PERENUAL_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
const token = await getToken();
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const url = `${BASE_URL}/api/species/details/${encodeURIComponent(id)}?key=${encodeURIComponent(apiKey)}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const url = `${BASE_URL}/plant/detail/${encodeURIComponent(id)}?include=care`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const item = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
@@ -4,9 +4,11 @@ import calendarManifest from "./calendar/manifest";
|
||||
import listsManifest from "./lists/manifest";
|
||||
import notesManifest from "./notes/manifest";
|
||||
import gardenManifest from "./garden/manifest";
|
||||
import bangsManifest from "./bangs/manifest";
|
||||
|
||||
registerModule(coreManifest);
|
||||
registerModule(calendarManifest);
|
||||
registerModule(listsManifest);
|
||||
registerModule(notesManifest);
|
||||
registerModule(gardenManifest);
|
||||
registerModule(bangsManifest);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
Reference in New Issue
Block a user