Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,11 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
@@ -113,6 +113,13 @@
|
||||
"when": 1748736000000,
|
||||
"tag": "0015_garden_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1748822400000,
|
||||
"tag": "0016_garden_improvements",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "famapp",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.3",
|
||||
@@ -48,7 +48,6 @@
|
||||
"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",
|
||||
@@ -56,7 +55,6 @@
|
||||
"@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",
|
||||
@@ -98,7 +96,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
-24
@@ -83,9 +83,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 +90,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))
|
||||
@@ -117,9 +111,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
|
||||
@@ -1877,10 +1868,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==}
|
||||
|
||||
@@ -6564,13 +6551,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
|
||||
@@ -10093,10 +10073,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
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>;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
Reference in New Issue
Block a user