mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
✨ feat(planta): add plant care tracking application
Add new Planta project for plant care management with: Backend (NestJS): - Plant CRUD with species, location, and care requirements - Watering tracking and scheduling - Photo management with S3 storage - AI-powered plant analysis using Google Gemini Vision API - Drizzle ORM with PostgreSQL schema Web (SvelteKit): - Dashboard with plant overview - Plant detail pages with care history - Add/edit plant forms - Auth integration with login/register routes - API client layer for all endpoints Infrastructure: - Database setup in setup-databases.sh - MinIO bucket for plant photos - Environment variables for port 3022 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9afae2efd2
commit
e22961e580
65 changed files with 3840 additions and 3 deletions
|
|
@ -46,7 +46,7 @@ JWT_ACCESS_TOKEN_EXPIRY=15m
|
||||||
JWT_REFRESH_TOKEN_EXPIRY=7d
|
JWT_REFRESH_TOKEN_EXPIRY=7d
|
||||||
JWT_ISSUER=manacore
|
JWT_ISSUER=manacore
|
||||||
JWT_AUDIENCE=manacore
|
JWT_AUDIENCE=manacore
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:5177,http://localhost:5178,http://localhost:5179,http://localhost:5180,http://localhost:5181,http://localhost:5182,http://localhost:5183,http://localhost:5184,http://localhost:5185,http://localhost:5186,http://localhost:5187,http://localhost:5188,http://localhost:5189,http://localhost:5190,http://localhost:8081
|
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:5177,http://localhost:5178,http://localhost:5179,http://localhost:5180,http://localhost:5181,http://localhost:5182,http://localhost:5183,http://localhost:5184,http://localhost:5185,http://localhost:5186,http://localhost:5187,http://localhost:5188,http://localhost:5189,http://localhost:5190,http://localhost:5191,http://localhost:8081
|
||||||
CREDITS_SIGNUP_BONUS=150
|
CREDITS_SIGNUP_BONUS=150
|
||||||
CREDITS_DAILY_FREE=5
|
CREDITS_DAILY_FREE=5
|
||||||
RATE_LIMIT_TTL=60
|
RATE_LIMIT_TTL=60
|
||||||
|
|
@ -274,6 +274,17 @@ INVENTORY_S3_PUBLIC_URL=http://localhost:9000/inventory-storage
|
||||||
TECHBASE_BACKEND_PORT=3021
|
TECHBASE_BACKEND_PORT=3021
|
||||||
TECHBASE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/techbase
|
TECHBASE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/techbase
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# PLANTA PROJECT
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
PLANTA_BACKEND_PORT=3022
|
||||||
|
PLANTA_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/planta
|
||||||
|
PLANTA_S3_PUBLIC_URL=http://localhost:9000/planta-storage
|
||||||
|
|
||||||
|
# Google Gemini API for plant vision analysis
|
||||||
|
PLANTA_GEMINI_API_KEY=AIzaSyC_-hPWpVttTlqJdU4jbXR5H0OAnRi2LgI
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# WORLDREAM GAME
|
# WORLDREAM GAME
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
167
apps/planta/CLAUDE.md
Normal file
167
apps/planta/CLAUDE.md
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
# Planta Project Guide
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/planta/
|
||||||
|
├── apps/
|
||||||
|
│ ├── backend/ # NestJS API server (@planta/backend)
|
||||||
|
│ └── web/ # SvelteKit web application (@planta/web)
|
||||||
|
├── packages/
|
||||||
|
│ └── shared/ # Shared types, utils (@planta/shared)
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Root Level (from monorepo root)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm planta:dev # Run all planta apps
|
||||||
|
pnpm dev:planta:web # Start web app
|
||||||
|
pnpm dev:planta:backend # Start backend server
|
||||||
|
pnpm dev:planta:app # Start web + backend together
|
||||||
|
pnpm dev:planta:full # Start auth + backend + web with DB setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (apps/planta/apps/backend)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Start with hot reload
|
||||||
|
pnpm build # Build for production
|
||||||
|
pnpm start:prod # Start production server
|
||||||
|
pnpm db:push # Push schema to database
|
||||||
|
pnpm db:studio # Open Drizzle Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web App (apps/planta/apps/web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Start dev server
|
||||||
|
pnpm build # Build for production
|
||||||
|
pnpm preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS
|
||||||
|
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
|
||||||
|
- **AI**: Google Gemini Vision for plant analysis
|
||||||
|
- **Storage**: MinIO (local) / Hetzner S3 (prod)
|
||||||
|
- **Auth**: Mana Core Auth (JWT)
|
||||||
|
- **Types**: TypeScript 5.x
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Flow
|
||||||
|
|
||||||
|
1. User uploads plant photo
|
||||||
|
2. Photo stored in S3/MinIO
|
||||||
|
3. Gemini Vision analyzes the image
|
||||||
|
4. Plant profile created with care recommendations
|
||||||
|
5. Watering schedule tracked
|
||||||
|
|
||||||
|
### Backend API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
| ------------------------------- | ------ | ------------------------ |
|
||||||
|
| `/api/health` | GET | Health check |
|
||||||
|
| `/api/plants` | GET | Get user's plants |
|
||||||
|
| `/api/plants` | POST | Create new plant |
|
||||||
|
| `/api/plants/:id` | GET | Get plant details |
|
||||||
|
| `/api/plants/:id` | PUT | Update plant |
|
||||||
|
| `/api/plants/:id` | DELETE | Delete plant |
|
||||||
|
| `/api/photos/upload` | POST | Upload plant photo |
|
||||||
|
| `/api/photos/:id` | DELETE | Delete photo |
|
||||||
|
| `/api/analysis/identify` | POST | Analyze photo with AI |
|
||||||
|
| `/api/analysis/:photoId` | GET | Get analysis results |
|
||||||
|
| `/api/watering/upcoming` | GET | Plants needing water |
|
||||||
|
| `/api/watering/:plantId/water` | POST | Log watering event |
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
**plants** - User's plants
|
||||||
|
|
||||||
|
- `id` (UUID) - Primary key
|
||||||
|
- `user_id` (TEXT) - User reference
|
||||||
|
- `name` (TEXT) - Plant nickname
|
||||||
|
- `scientific_name` (TEXT) - From AI analysis
|
||||||
|
- `common_name` (TEXT) - Common name
|
||||||
|
- `light_requirements` (TEXT) - low/medium/bright/direct
|
||||||
|
- `watering_frequency_days` (INT) - Days between watering
|
||||||
|
- `humidity` (TEXT) - low/medium/high
|
||||||
|
- `care_notes` (TEXT) - Care tips
|
||||||
|
- `health_status` (TEXT) - healthy/needs_attention/sick
|
||||||
|
|
||||||
|
**plant_photos** - Plant photos
|
||||||
|
|
||||||
|
- `id` (UUID) - Primary key
|
||||||
|
- `plant_id` (UUID) - FK to plants
|
||||||
|
- `storage_path` (TEXT) - S3 path
|
||||||
|
- `public_url` (TEXT) - Public URL
|
||||||
|
- `is_primary` (BOOLEAN) - Primary photo flag
|
||||||
|
- `is_analyzed` (BOOLEAN) - Analysis flag
|
||||||
|
|
||||||
|
**plant_analyses** - AI analysis results
|
||||||
|
|
||||||
|
- `id` (UUID) - Primary key
|
||||||
|
- `photo_id` (UUID) - FK to plant_photos
|
||||||
|
- `identified_species` (TEXT) - Detected species
|
||||||
|
- `confidence` (INT) - 0-100 confidence
|
||||||
|
- `health_assessment` (TEXT) - Health status
|
||||||
|
- `watering_advice` (TEXT) - Watering recommendation
|
||||||
|
- `general_tips` (JSONB) - Care tips array
|
||||||
|
|
||||||
|
**watering_schedules** - Watering tracking
|
||||||
|
|
||||||
|
- `id` (UUID) - Primary key
|
||||||
|
- `plant_id` (UUID) - FK to plants
|
||||||
|
- `frequency_days` (INT) - Interval
|
||||||
|
- `last_watered_at` (TIMESTAMP) - Last watering
|
||||||
|
- `next_watering_at` (TIMESTAMP) - Next watering
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
#### Backend (.env)
|
||||||
|
|
||||||
|
```
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3022
|
||||||
|
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/planta
|
||||||
|
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||||
|
GOOGLE_GEMINI_API_KEY=xxx
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:5191
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_BUCKET=planta-storage
|
||||||
|
S3_ACCESS_KEY=minioadmin
|
||||||
|
S3_SECRET_KEY=minioadmin
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Web (.env)
|
||||||
|
|
||||||
|
```
|
||||||
|
PUBLIC_BACKEND_URL=http://localhost:3022
|
||||||
|
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared Package
|
||||||
|
|
||||||
|
### @planta/shared
|
||||||
|
|
||||||
|
- Types: `Plant`, `PlantPhoto`, `PlantAnalysis`, `WateringSchedule`
|
||||||
|
- Utils: Date helpers, care level formatters
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
- **TypeScript**: Strict typing with interfaces
|
||||||
|
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **Formatting**: Prettier with project config
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
|
||||||
|
2. **Database**: PostgreSQL with Drizzle ORM
|
||||||
|
3. **Port**: Backend runs on port 3022 by default
|
||||||
|
4. **Storage**: Photos stored in MinIO (local) / Hetzner S3 (prod)
|
||||||
|
5. **AI**: Google Gemini Vision for plant identification
|
||||||
12
apps/planta/apps/backend/drizzle.config.ts
Normal file
12
apps/planta/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './src/db/schema/index.ts',
|
||||||
|
out: './src/db/migrations',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/planta',
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
8
apps/planta/apps/backend/nest-cli.json
Normal file
8
apps/planta/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/planta/apps/backend/package.json
Normal file
60
apps/planta/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"name": "@planta/backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"migration:generate": "drizzle-kit generate",
|
||||||
|
"migration:run": "tsx src/db/migrate.ts",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "tsx src/db/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.21.0",
|
||||||
|
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||||
|
"@manacore/shared-storage": "workspace:*",
|
||||||
|
"@nestjs/common": "^10.4.15",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/core": "^10.4.15",
|
||||||
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
|
"@planta/shared": "workspace:*",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"drizzle-kit": "^0.30.2",
|
||||||
|
"drizzle-orm": "^0.38.3",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"postgres": "^3.4.5",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"uuid": "^11.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.4.9",
|
||||||
|
"@nestjs/schematics": "^10.2.3",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||||
|
"@typescript-eslint/parser": "^8.18.1",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/planta/apps/backend/src/analysis/analysis.controller.ts
Normal file
29
apps/planta/apps/backend/src/analysis/analysis.controller.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Controller, Post, Get, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||||
|
import { AnalysisService } from './analysis.service';
|
||||||
|
import { IsString, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
class AnalyzePhotoDto {
|
||||||
|
@IsString()
|
||||||
|
photoId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
plantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('analysis')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class AnalysisController {
|
||||||
|
constructor(private readonly analysisService: AnalysisService) {}
|
||||||
|
|
||||||
|
@Post('identify')
|
||||||
|
async analyzePhoto(@CurrentUser() user: CurrentUserData, @Body() dto: AnalyzePhotoDto) {
|
||||||
|
return this.analysisService.analyzePhoto(dto.photoId, user.userId, dto.plantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':photoId')
|
||||||
|
async getAnalysis(@Param('photoId') photoId: string, @CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.analysisService.getAnalysis(photoId, user.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/planta/apps/backend/src/analysis/analysis.module.ts
Normal file
14
apps/planta/apps/backend/src/analysis/analysis.module.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AnalysisController } from './analysis.controller';
|
||||||
|
import { AnalysisService } from './analysis.service';
|
||||||
|
import { VisionService } from './vision.service';
|
||||||
|
import { PhotoModule } from '../photo/photo.module';
|
||||||
|
import { PlantModule } from '../plant/plant.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PhotoModule, PlantModule],
|
||||||
|
controllers: [AnalysisController],
|
||||||
|
providers: [AnalysisService, VisionService],
|
||||||
|
exports: [AnalysisService],
|
||||||
|
})
|
||||||
|
export class AnalysisModule {}
|
||||||
131
apps/planta/apps/backend/src/analysis/analysis.service.ts
Normal file
131
apps/planta/apps/backend/src/analysis/analysis.service.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
import { plantAnalyses, plantPhotos } from '../db/schema';
|
||||||
|
import { VisionService } from './vision.service';
|
||||||
|
import { PhotoService } from '../photo/photo.service';
|
||||||
|
import { PlantService } from '../plant/plant.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AnalysisService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||||
|
private visionService: VisionService,
|
||||||
|
private photoService: PhotoService,
|
||||||
|
private plantService: PlantService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async analyzePhoto(photoId: string, userId: string, plantId?: string) {
|
||||||
|
// Get photo
|
||||||
|
const photo = await this.photoService.findOne(photoId, userId);
|
||||||
|
|
||||||
|
if (photo.isAnalyzed) {
|
||||||
|
// Return existing analysis
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plantAnalyses)
|
||||||
|
.where(eq(plantAnalyses.photoId, photoId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download image for analysis
|
||||||
|
const imageBuffer = await this.photoService.getPhotoBuffer(photo.storagePath);
|
||||||
|
|
||||||
|
// Analyze with Gemini Vision
|
||||||
|
const result = await this.visionService.analyzePlantImage(
|
||||||
|
imageBuffer,
|
||||||
|
photo.mimeType || 'image/jpeg'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new BadRequestException('Failed to analyze plant image');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store analysis
|
||||||
|
const [analysis] = await this.db
|
||||||
|
.insert(plantAnalyses)
|
||||||
|
.values({
|
||||||
|
photoId,
|
||||||
|
plantId: plantId || photo.plantId,
|
||||||
|
userId,
|
||||||
|
identifiedSpecies: result.identification.scientificName,
|
||||||
|
scientificName: result.identification.scientificName,
|
||||||
|
commonNames: result.identification.commonNames,
|
||||||
|
confidence: result.identification.confidence,
|
||||||
|
healthAssessment: result.health.status,
|
||||||
|
healthDetails: result.health.details,
|
||||||
|
issues: result.health.issues,
|
||||||
|
wateringAdvice: `Alle ${result.care.wateringFrequencyDays} Tage gießen`,
|
||||||
|
lightAdvice: this.formatLightAdvice(result.care.light),
|
||||||
|
generalTips: result.care.tips,
|
||||||
|
rawResponse: result,
|
||||||
|
model: 'gemini-2.0-flash',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Mark photo as analyzed
|
||||||
|
await this.photoService.markAnalyzed(photoId);
|
||||||
|
|
||||||
|
// If linked to a plant, update plant with analysis data
|
||||||
|
const targetPlantId = plantId || photo.plantId;
|
||||||
|
if (targetPlantId) {
|
||||||
|
await this.plantService.updateFromAnalysis(targetPlantId, {
|
||||||
|
scientificName: result.identification.scientificName,
|
||||||
|
commonName: result.identification.commonNames[0],
|
||||||
|
lightRequirements: result.care.light,
|
||||||
|
wateringFrequencyDays: result.care.wateringFrequencyDays,
|
||||||
|
humidity: result.care.humidity,
|
||||||
|
temperature: result.care.temperature,
|
||||||
|
soilType: result.care.soilType,
|
||||||
|
careNotes: result.care.tips.join('\n'),
|
||||||
|
healthStatus: this.mapHealthStatus(result.health.status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAnalysis(photoId: string, userId: string) {
|
||||||
|
const [analysis] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plantAnalyses)
|
||||||
|
.where(eq(plantAnalyses.photoId, photoId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!analysis) {
|
||||||
|
throw new NotFoundException('Analysis not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user owns this analysis
|
||||||
|
if (analysis.userId !== userId) {
|
||||||
|
throw new NotFoundException('Analysis not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatLightAdvice(light: string): string {
|
||||||
|
const lightMap: Record<string, string> = {
|
||||||
|
low: 'Wenig Licht - Schattige Standorte geeignet',
|
||||||
|
medium: 'Mittleres Licht - Heller Standort ohne direkte Sonne',
|
||||||
|
bright: 'Helles Licht - Heller Standort mit indirektem Sonnenlicht',
|
||||||
|
direct: 'Direkte Sonne - Sonniger Standort mit direkter Sonneneinstrahlung',
|
||||||
|
};
|
||||||
|
return lightMap[light] || 'Mittleres Licht empfohlen';
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapHealthStatus(assessment: string): string {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
healthy: 'healthy',
|
||||||
|
minor_issues: 'needs_attention',
|
||||||
|
needs_care: 'needs_attention',
|
||||||
|
critical: 'sick',
|
||||||
|
};
|
||||||
|
return statusMap[assessment] || 'healthy';
|
||||||
|
}
|
||||||
|
}
|
||||||
164
apps/planta/apps/backend/src/analysis/vision.service.ts
Normal file
164
apps/planta/apps/backend/src/analysis/vision.service.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||||
|
import type { AnalysisResult } from '@planta/shared';
|
||||||
|
|
||||||
|
const PLANT_ANALYSIS_PROMPT = `Du bist ein erfahrener Botaniker und Pflanzenexperte. Analysiere dieses Pflanzenfoto und erstelle einen detaillierten Steckbrief.
|
||||||
|
|
||||||
|
Antworte NUR mit validem JSON (keine Markdown-Codeblocks, kein anderer Text) in diesem Format:
|
||||||
|
|
||||||
|
{
|
||||||
|
"identification": {
|
||||||
|
"scientificName": "Botanischer Name (Gattung und Art)",
|
||||||
|
"commonNames": ["Deutscher Name", "Alternative Namen"],
|
||||||
|
"confidence": 85
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"status": "healthy",
|
||||||
|
"issues": [],
|
||||||
|
"details": "Beschreibung des Gesundheitszustands"
|
||||||
|
},
|
||||||
|
"care": {
|
||||||
|
"light": "bright",
|
||||||
|
"wateringFrequencyDays": 7,
|
||||||
|
"humidity": "medium",
|
||||||
|
"temperature": "18-24°C",
|
||||||
|
"soilType": "Gut durchlässige Blumenerde",
|
||||||
|
"tips": ["Pflegetipp 1", "Pflegetipp 2", "Pflegetipp 3"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- scientificName: Botanischer Name (z.B. "Monstera deliciosa")
|
||||||
|
- commonNames: Array mit deutschen Namen
|
||||||
|
- confidence: Zahl von 0-100 (wie sicher bist du bei der Identifikation)
|
||||||
|
- health.status: "healthy" | "minor_issues" | "needs_care" | "critical"
|
||||||
|
- health.issues: Array mit erkannten Problemen (leer wenn gesund)
|
||||||
|
- care.light: "low" | "medium" | "bright" | "direct"
|
||||||
|
- care.wateringFrequencyDays: Anzahl Tage zwischen Gießvorgängen
|
||||||
|
- care.humidity: "low" | "medium" | "high"
|
||||||
|
- care.tips: 3-5 praktische Pflegetipps auf Deutsch
|
||||||
|
|
||||||
|
Falls du die Pflanze nicht identifizieren kannst, setze confidence auf 0 und scientificName auf "Unbekannt".`;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VisionService {
|
||||||
|
private readonly logger = new Logger(VisionService.name);
|
||||||
|
private genAI: GoogleGenerativeAI | null = null;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
const apiKey = this.configService.get<string>('GOOGLE_GEMINI_API_KEY');
|
||||||
|
if (apiKey) {
|
||||||
|
this.genAI = new GoogleGenerativeAI(apiKey);
|
||||||
|
this.logger.log('Gemini Vision AI initialized');
|
||||||
|
} else {
|
||||||
|
this.logger.warn('GOOGLE_GEMINI_API_KEY not configured - Vision analysis disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzePlantImage(imageBuffer: Buffer, mimeType: string): Promise<AnalysisResult | null> {
|
||||||
|
if (!this.genAI) {
|
||||||
|
this.logger.error('Gemini AI not configured');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
|
||||||
|
|
||||||
|
const imagePart = {
|
||||||
|
inlineData: {
|
||||||
|
data: imageBuffer.toString('base64'),
|
||||||
|
mimeType: mimeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await model.generateContent([PLANT_ANALYSIS_PROMPT, imagePart]);
|
||||||
|
const response = result.response.text().trim();
|
||||||
|
|
||||||
|
this.logger.debug(`Gemini raw response: ${response}`);
|
||||||
|
|
||||||
|
// Parse JSON response - handle potential markdown code blocks
|
||||||
|
let jsonStr = response;
|
||||||
|
if (response.includes('```')) {
|
||||||
|
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
|
if (match) {
|
||||||
|
jsonStr = match[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonStr) as AnalysisResult;
|
||||||
|
|
||||||
|
// Validate and sanitize response
|
||||||
|
this.validateAnalysisResult(parsed);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Plant identified: ${parsed.identification.scientificName} (${parsed.identification.confidence}% confidence)`
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Vision analysis failed: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateAnalysisResult(result: AnalysisResult): void {
|
||||||
|
// Validate identification
|
||||||
|
if (!result.identification) {
|
||||||
|
result.identification = {
|
||||||
|
scientificName: 'Unbekannt',
|
||||||
|
commonNames: [],
|
||||||
|
confidence: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure confidence is within range
|
||||||
|
if (typeof result.identification.confidence !== 'number') {
|
||||||
|
result.identification.confidence = 0;
|
||||||
|
}
|
||||||
|
result.identification.confidence = Math.max(0, Math.min(100, result.identification.confidence));
|
||||||
|
|
||||||
|
// Validate health
|
||||||
|
if (!result.health) {
|
||||||
|
result.health = {
|
||||||
|
status: 'healthy',
|
||||||
|
issues: [],
|
||||||
|
details: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validHealthStatuses = ['healthy', 'minor_issues', 'needs_care', 'critical'];
|
||||||
|
if (!validHealthStatuses.includes(result.health.status)) {
|
||||||
|
result.health.status = 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate care
|
||||||
|
if (!result.care) {
|
||||||
|
result.care = {
|
||||||
|
light: 'medium',
|
||||||
|
wateringFrequencyDays: 7,
|
||||||
|
humidity: 'medium',
|
||||||
|
temperature: '18-24°C',
|
||||||
|
soilType: 'Blumenerde',
|
||||||
|
tips: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validLightLevels = ['low', 'medium', 'bright', 'direct'];
|
||||||
|
if (!validLightLevels.includes(result.care.light)) {
|
||||||
|
result.care.light = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const validHumidityLevels = ['low', 'medium', 'high'];
|
||||||
|
if (!validHumidityLevels.includes(result.care.humidity)) {
|
||||||
|
result.care.humidity = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof result.care.wateringFrequencyDays !== 'number' ||
|
||||||
|
result.care.wateringFrequencyDays < 1
|
||||||
|
) {
|
||||||
|
result.care.wateringFrequencyDays = 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/planta/apps/backend/src/app.module.ts
Normal file
24
apps/planta/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { DatabaseModule } from './db/database.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { PlantModule } from './plant/plant.module';
|
||||||
|
import { PhotoModule } from './photo/photo.module';
|
||||||
|
import { AnalysisModule } from './analysis/analysis.module';
|
||||||
|
import { WateringModule } from './watering/watering.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: '.env',
|
||||||
|
}),
|
||||||
|
DatabaseModule,
|
||||||
|
HealthModule,
|
||||||
|
PlantModule,
|
||||||
|
PhotoModule,
|
||||||
|
AnalysisModule,
|
||||||
|
WateringModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
38
apps/planta/apps/backend/src/db/connection.ts
Normal file
38
apps/planta/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const postgres = require('postgres');
|
||||||
|
|
||||||
|
let connection: ReturnType<typeof postgres> | null = null;
|
||||||
|
let db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
export function getConnection(databaseUrl: string) {
|
||||||
|
if (!connection) {
|
||||||
|
connection = postgres(databaseUrl, {
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 20,
|
||||||
|
connect_timeout: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(databaseUrl: string) {
|
||||||
|
if (!db) {
|
||||||
|
const conn = getConnection(databaseUrl);
|
||||||
|
db = drizzle(conn, { schema });
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeConnection() {
|
||||||
|
if (connection) {
|
||||||
|
await connection.end();
|
||||||
|
connection = null;
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Database = ReturnType<typeof getDb>;
|
||||||
29
apps/planta/apps/backend/src/db/database.module.ts
Normal file
29
apps/planta/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { getDb, closeConnection } from './connection';
|
||||||
|
import type { Database } from './connection';
|
||||||
|
|
||||||
|
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: DATABASE_CONNECTION,
|
||||||
|
useFactory: (configService: ConfigService): Database => {
|
||||||
|
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL environment variable is not set');
|
||||||
|
}
|
||||||
|
return getDb(databaseUrl);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [DATABASE_CONNECTION],
|
||||||
|
})
|
||||||
|
export class DatabaseModule implements OnModuleDestroy {
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await closeConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/planta/apps/backend/src/db/schema/index.ts
Normal file
4
apps/planta/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './plants.schema';
|
||||||
|
export * from './plant-photos.schema';
|
||||||
|
export * from './plant-analyses.schema';
|
||||||
|
export * from './watering.schema';
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { pgTable, uuid, text, timestamp, jsonb, integer } from 'drizzle-orm/pg-core';
|
||||||
|
import { plantPhotos } from './plant-photos.schema';
|
||||||
|
import { plants } from './plants.schema';
|
||||||
|
|
||||||
|
export const plantAnalyses = pgTable('plant_analyses', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
photoId: uuid('photo_id')
|
||||||
|
.references(() => plantPhotos.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
plantId: uuid('plant_id').references(() => plants.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
|
||||||
|
// AI Analysis Results
|
||||||
|
identifiedSpecies: text('identified_species'),
|
||||||
|
scientificName: text('scientific_name'),
|
||||||
|
commonNames: jsonb('common_names').$type<string[]>(),
|
||||||
|
confidence: integer('confidence'),
|
||||||
|
|
||||||
|
// Plant condition
|
||||||
|
healthAssessment: text('health_assessment'),
|
||||||
|
healthDetails: text('health_details'),
|
||||||
|
issues: jsonb('issues').$type<string[]>(),
|
||||||
|
|
||||||
|
// Care recommendations
|
||||||
|
wateringAdvice: text('watering_advice'),
|
||||||
|
lightAdvice: text('light_advice'),
|
||||||
|
fertilizingAdvice: text('fertilizing_advice'),
|
||||||
|
generalTips: jsonb('general_tips').$type<string[]>(),
|
||||||
|
|
||||||
|
// Raw AI response for debugging
|
||||||
|
rawResponse: jsonb('raw_response'),
|
||||||
|
model: text('model'),
|
||||||
|
tokensUsed: integer('tokens_used'),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PlantAnalysis = typeof plantAnalyses.$inferSelect;
|
||||||
|
export type NewPlantAnalysis = typeof plantAnalyses.$inferInsert;
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||||
|
import { plants } from './plants.schema';
|
||||||
|
|
||||||
|
export const plantPhotos = pgTable('plant_photos', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
plantId: uuid('plant_id').references(() => plants.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
storagePath: text('storage_path').notNull(),
|
||||||
|
publicUrl: text('public_url'),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
mimeType: text('mime_type'),
|
||||||
|
fileSize: integer('file_size'),
|
||||||
|
|
||||||
|
// Image metadata
|
||||||
|
width: integer('width'),
|
||||||
|
height: integer('height'),
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
isPrimary: boolean('is_primary').default(false).notNull(),
|
||||||
|
isAnalyzed: boolean('is_analyzed').default(false).notNull(),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
takenAt: timestamp('taken_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PlantPhoto = typeof plantPhotos.$inferSelect;
|
||||||
|
export type NewPlantPhoto = typeof plantPhotos.$inferInsert;
|
||||||
32
apps/planta/apps/backend/src/db/schema/plants.schema.ts
Normal file
32
apps/planta/apps/backend/src/db/schema/plants.schema.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const plants = pgTable('plants', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
|
||||||
|
// Plant identity
|
||||||
|
name: text('name').notNull(),
|
||||||
|
scientificName: text('scientific_name'),
|
||||||
|
commonName: text('common_name'),
|
||||||
|
species: text('species'),
|
||||||
|
|
||||||
|
// Care info (from AI)
|
||||||
|
lightRequirements: text('light_requirements'),
|
||||||
|
wateringFrequencyDays: integer('watering_frequency_days'),
|
||||||
|
humidity: text('humidity'),
|
||||||
|
temperature: text('temperature'),
|
||||||
|
soilType: text('soil_type'),
|
||||||
|
careNotes: text('care_notes'),
|
||||||
|
|
||||||
|
// Status
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
healthStatus: text('health_status'),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
acquiredAt: timestamp('acquired_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Plant = typeof plants.$inferSelect;
|
||||||
|
export type NewPlant = typeof plants.$inferInsert;
|
||||||
45
apps/planta/apps/backend/src/db/schema/watering.schema.ts
Normal file
45
apps/planta/apps/backend/src/db/schema/watering.schema.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||||
|
import { plants } from './plants.schema';
|
||||||
|
|
||||||
|
export const wateringSchedules = pgTable('watering_schedules', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
plantId: uuid('plant_id')
|
||||||
|
.references(() => plants.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
|
||||||
|
// Schedule config
|
||||||
|
frequencyDays: integer('frequency_days').notNull(),
|
||||||
|
|
||||||
|
// Tracking
|
||||||
|
lastWateredAt: timestamp('last_watered_at', { withTimezone: true }),
|
||||||
|
nextWateringAt: timestamp('next_watering_at', { withTimezone: true }),
|
||||||
|
|
||||||
|
// Notification preferences
|
||||||
|
reminderEnabled: boolean('reminder_enabled').default(true).notNull(),
|
||||||
|
reminderHoursBefore: integer('reminder_hours_before').default(24),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WateringSchedule = typeof wateringSchedules.$inferSelect;
|
||||||
|
export type NewWateringSchedule = typeof wateringSchedules.$inferInsert;
|
||||||
|
|
||||||
|
// Watering log for history tracking
|
||||||
|
export const wateringLogs = pgTable('watering_logs', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
plantId: uuid('plant_id')
|
||||||
|
.references(() => plants.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
|
||||||
|
wateredAt: timestamp('watered_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
notes: text('notes'),
|
||||||
|
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WateringLog = typeof wateringLogs.$inferSelect;
|
||||||
|
export type NewWateringLog = typeof wateringLogs.$inferInsert;
|
||||||
13
apps/planta/apps/backend/src/health/health.controller.ts
Normal file
13
apps/planta/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'planta-backend',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/planta/apps/backend/src/health/health.module.ts
Normal file
7
apps/planta/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
38
apps/planta/apps/backend/src/main.ts
Normal file
38
apps/planta/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Enable CORS for web app
|
||||||
|
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://localhost:5191',
|
||||||
|
'http://localhost:3001',
|
||||||
|
];
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: corsOrigins,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable validation
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set global prefix for API routes
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3022;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Planta backend running on http://localhost:${port}`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
57
apps/planta/apps/backend/src/photo/photo.controller.ts
Normal file
57
apps/planta/apps/backend/src/photo/photo.controller.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Put,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
ParseFilePipe,
|
||||||
|
MaxFileSizeValidator,
|
||||||
|
FileTypeValidator,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||||
|
import { PhotoService } from './photo.service';
|
||||||
|
|
||||||
|
@Controller('photos')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class PhotoController {
|
||||||
|
constructor(private readonly photoService: PhotoService) {}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async upload(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [
|
||||||
|
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB
|
||||||
|
new FileTypeValidator({ fileType: /(jpg|jpeg|png|webp|heic)$/i }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
file: Express.Multer.File,
|
||||||
|
@Query('plantId') plantId?: string
|
||||||
|
) {
|
||||||
|
return this.photoService.upload(user.userId, file, plantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.photoService.findOne(id, user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.photoService.delete(id, user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/primary')
|
||||||
|
async setPrimary(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.photoService.setPrimary(id, user.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/planta/apps/backend/src/photo/photo.module.ts
Normal file
19
apps/planta/apps/backend/src/photo/photo.module.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
|
import { PhotoController } from './photo.controller';
|
||||||
|
import { PhotoService } from './photo.service';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MulterModule.register({
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [PhotoController],
|
||||||
|
providers: [PhotoService, StorageService],
|
||||||
|
exports: [PhotoService, StorageService],
|
||||||
|
})
|
||||||
|
export class PhotoModule {}
|
||||||
114
apps/planta/apps/backend/src/photo/photo.service.ts
Normal file
114
apps/planta/apps/backend/src/photo/photo.service.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
import { plantPhotos } from '../db/schema';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PhotoService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||||
|
private storageService: StorageService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upload(userId: string, file: Express.Multer.File, plantId?: string) {
|
||||||
|
// Upload to storage
|
||||||
|
const { storagePath, publicUrl } = await this.storageService.uploadPhoto(userId, file);
|
||||||
|
|
||||||
|
// Create photo record
|
||||||
|
const [photo] = await this.db
|
||||||
|
.insert(plantPhotos)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
plantId: plantId || null,
|
||||||
|
storagePath,
|
||||||
|
publicUrl,
|
||||||
|
filename: file.originalname,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
fileSize: file.size,
|
||||||
|
isPrimary: false,
|
||||||
|
isAnalyzed: false,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// If this is the first photo for the plant, set it as primary
|
||||||
|
if (plantId) {
|
||||||
|
const existingPhotos = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plantPhotos)
|
||||||
|
.where(eq(plantPhotos.plantId, plantId));
|
||||||
|
|
||||||
|
if (existingPhotos.length === 1) {
|
||||||
|
await this.db
|
||||||
|
.update(plantPhotos)
|
||||||
|
.set({ isPrimary: true })
|
||||||
|
.where(eq(plantPhotos.id, photo.id));
|
||||||
|
photo.isPrimary = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string, userId: string) {
|
||||||
|
const [photo] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plantPhotos)
|
||||||
|
.where(and(eq(plantPhotos.id, id), eq(plantPhotos.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
throw new NotFoundException('Photo not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, userId: string) {
|
||||||
|
const photo = await this.findOne(id, userId);
|
||||||
|
|
||||||
|
// Delete from storage
|
||||||
|
await this.storageService.deletePhoto(photo.storagePath);
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
await this.db.delete(plantPhotos).where(eq(plantPhotos.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPrimary(id: string, userId: string) {
|
||||||
|
const photo = await this.findOne(id, userId);
|
||||||
|
|
||||||
|
if (!photo.plantId) {
|
||||||
|
throw new NotFoundException('Photo is not associated with a plant');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove primary from all photos of this plant
|
||||||
|
await this.db
|
||||||
|
.update(plantPhotos)
|
||||||
|
.set({ isPrimary: false })
|
||||||
|
.where(eq(plantPhotos.plantId, photo.plantId));
|
||||||
|
|
||||||
|
// Set this photo as primary
|
||||||
|
await this.db.update(plantPhotos).set({ isPrimary: true }).where(eq(plantPhotos.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async linkToPlant(photoId: string, plantId: string, userId: string) {
|
||||||
|
const photo = await this.findOne(photoId, userId);
|
||||||
|
|
||||||
|
await this.db.update(plantPhotos).set({ plantId }).where(eq(plantPhotos.id, photo.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAnalyzed(photoId: string) {
|
||||||
|
await this.db.update(plantPhotos).set({ isAnalyzed: true }).where(eq(plantPhotos.id, photoId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPhotoBuffer(storagePath: string): Promise<Buffer> {
|
||||||
|
return this.storageService.downloadPhoto(storagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/planta/apps/backend/src/photo/storage.service.ts
Normal file
55
apps/planta/apps/backend/src/photo/storage.service.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createStorageClient, StorageClient } from '@manacore/shared-storage';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorageService {
|
||||||
|
private storage: StorageClient;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
const publicUrl = this.configService.get<string>('PLANTA_S3_PUBLIC_URL');
|
||||||
|
this.storage = createStorageClient(
|
||||||
|
{
|
||||||
|
name: 'planta-storage',
|
||||||
|
publicUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
endpoint: this.configService.get<string>('S3_ENDPOINT'),
|
||||||
|
region: this.configService.get<string>('S3_REGION'),
|
||||||
|
accessKeyId: this.configService.get<string>('S3_ACCESS_KEY'),
|
||||||
|
secretAccessKey: this.configService.get<string>('S3_SECRET_KEY'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadPhoto(
|
||||||
|
userId: string,
|
||||||
|
file: Express.Multer.File
|
||||||
|
): Promise<{ storagePath: string; publicUrl: string }> {
|
||||||
|
const extension = file.originalname.split('.').pop() || 'jpg';
|
||||||
|
const filename = `${uuidv4()}.${extension}`;
|
||||||
|
const storagePath = `users/${userId}/photos/${filename}`;
|
||||||
|
|
||||||
|
await this.storage.upload(storagePath, file.buffer, {
|
||||||
|
contentType: file.mimetype,
|
||||||
|
public: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicUrl = this.storage.getPublicUrl(storagePath) ?? '';
|
||||||
|
|
||||||
|
return { storagePath, publicUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePhoto(storagePath: string): Promise<void> {
|
||||||
|
await this.storage.delete(storagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPhotoUrl(storagePath: string): Promise<string> {
|
||||||
|
return this.storage.getPublicUrl(storagePath) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadPhoto(storagePath: string): Promise<Buffer> {
|
||||||
|
return this.storage.download(storagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/planta/apps/backend/src/plant/dto/create-plant.dto.ts
Normal file
18
apps/planta/apps/backend/src/plant/dto/create-plant.dto.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { IsString, IsOptional, IsDateString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePlantDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scientificName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
commonName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
acquiredAt?: string;
|
||||||
|
}
|
||||||
35
apps/planta/apps/backend/src/plant/dto/update-plant.dto.ts
Normal file
35
apps/planta/apps/backend/src/plant/dto/update-plant.dto.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { IsString, IsOptional, IsBoolean, IsNumber, IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdatePlantDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scientificName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
commonName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
careNotes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['low', 'medium', 'bright', 'direct'])
|
||||||
|
lightRequirements?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
wateringFrequencyDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['low', 'medium', 'high'])
|
||||||
|
humidity?: string;
|
||||||
|
}
|
||||||
40
apps/planta/apps/backend/src/plant/plant.controller.ts
Normal file
40
apps/planta/apps/backend/src/plant/plant.controller.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||||
|
import { PlantService } from './plant.service';
|
||||||
|
import { CreatePlantDto } from './dto/create-plant.dto';
|
||||||
|
import { UpdatePlantDto } from './dto/update-plant.dto';
|
||||||
|
|
||||||
|
@Controller('plants')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class PlantController {
|
||||||
|
constructor(private readonly plantService: PlantService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.plantService.findAll(user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.plantService.findOne(id, user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreatePlantDto) {
|
||||||
|
return this.plantService.create(user.userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: UpdatePlantDto
|
||||||
|
) {
|
||||||
|
return this.plantService.update(id, user.userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.plantService.delete(id, user.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/planta/apps/backend/src/plant/plant.module.ts
Normal file
10
apps/planta/apps/backend/src/plant/plant.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlantController } from './plant.controller';
|
||||||
|
import { PlantService } from './plant.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PlantController],
|
||||||
|
providers: [PlantService],
|
||||||
|
exports: [PlantService],
|
||||||
|
})
|
||||||
|
export class PlantModule {}
|
||||||
163
apps/planta/apps/backend/src/plant/plant.service.ts
Normal file
163
apps/planta/apps/backend/src/plant/plant.service.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
import { plants, plantPhotos, wateringSchedules, plantAnalyses } from '../db/schema';
|
||||||
|
import { CreatePlantDto } from './dto/create-plant.dto';
|
||||||
|
import { UpdatePlantDto } from './dto/update-plant.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlantService {
|
||||||
|
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||||
|
|
||||||
|
async findAll(userId: string) {
|
||||||
|
const userPlants = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plants)
|
||||||
|
.where(and(eq(plants.userId, userId), eq(plants.isActive, true)))
|
||||||
|
.orderBy(desc(plants.createdAt));
|
||||||
|
|
||||||
|
// Get primary photos for each plant
|
||||||
|
const plantsWithPhotos = await Promise.all(
|
||||||
|
userPlants.map(async (plant) => {
|
||||||
|
const photos = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plantPhotos)
|
||||||
|
.where(and(eq(plantPhotos.plantId, plant.id), eq(plantPhotos.isPrimary, true)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const schedule = await this.db
|
||||||
|
.select()
|
||||||
|
.from(wateringSchedules)
|
||||||
|
.where(eq(wateringSchedules.plantId, plant.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...plant,
|
||||||
|
primaryPhoto: photos[0] || null,
|
||||||
|
wateringSchedule: schedule[0] || null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return plantsWithPhotos;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string, userId: string) {
|
||||||
|
const [plant] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plants)
|
||||||
|
.where(and(eq(plants.id, id), eq(plants.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!plant) {
|
||||||
|
throw new NotFoundException('Plant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all photos
|
||||||
|
const photos = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plantPhotos)
|
||||||
|
.where(eq(plantPhotos.plantId, id))
|
||||||
|
.orderBy(desc(plantPhotos.isPrimary), desc(plantPhotos.createdAt));
|
||||||
|
|
||||||
|
// Get watering schedule
|
||||||
|
const [schedule] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(wateringSchedules)
|
||||||
|
.where(eq(wateringSchedules.plantId, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Get latest analysis
|
||||||
|
const [latestAnalysis] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plantAnalyses)
|
||||||
|
.where(eq(plantAnalyses.plantId, id))
|
||||||
|
.orderBy(desc(plantAnalyses.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...plant,
|
||||||
|
photos,
|
||||||
|
wateringSchedule: schedule || null,
|
||||||
|
latestAnalysis: latestAnalysis || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(userId: string, dto: CreatePlantDto) {
|
||||||
|
const [plant] = await this.db
|
||||||
|
.insert(plants)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
name: dto.name,
|
||||||
|
scientificName: dto.scientificName,
|
||||||
|
commonName: dto.commonName,
|
||||||
|
acquiredAt: dto.acquiredAt ? new Date(dto.acquiredAt) : null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return plant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, userId: string, dto: UpdatePlantDto) {
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plants)
|
||||||
|
.where(and(eq(plants.id, id), eq(plants.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Plant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await this.db
|
||||||
|
.update(plants)
|
||||||
|
.set({
|
||||||
|
...dto,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(plants.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, userId: string) {
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plants)
|
||||||
|
.where(and(eq(plants.id, id), eq(plants.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Plant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.delete(plants).where(eq(plants.id, id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFromAnalysis(
|
||||||
|
plantId: string,
|
||||||
|
analysis: {
|
||||||
|
scientificName?: string;
|
||||||
|
commonName?: string;
|
||||||
|
lightRequirements?: string;
|
||||||
|
wateringFrequencyDays?: number;
|
||||||
|
humidity?: string;
|
||||||
|
temperature?: string;
|
||||||
|
soilType?: string;
|
||||||
|
careNotes?: string;
|
||||||
|
healthStatus?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
await this.db
|
||||||
|
.update(plants)
|
||||||
|
.set({
|
||||||
|
...analysis,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(plants.id, plantId));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/planta/apps/backend/src/watering/watering.controller.ts
Normal file
50
apps/planta/apps/backend/src/watering/watering.controller.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Controller, Get, Post, Put, Param, Body, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||||
|
import { WateringService } from './watering.service';
|
||||||
|
import { IsOptional, IsString, IsNumber, Min } from 'class-validator';
|
||||||
|
|
||||||
|
class LogWateringDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateScheduleDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
frequencyDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('watering')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class WateringController {
|
||||||
|
constructor(private readonly wateringService: WateringService) {}
|
||||||
|
|
||||||
|
@Get('upcoming')
|
||||||
|
async getUpcoming(@CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.wateringService.getUpcoming(user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':plantId/water')
|
||||||
|
async logWatering(
|
||||||
|
@Param('plantId') plantId: string,
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: LogWateringDto
|
||||||
|
) {
|
||||||
|
return this.wateringService.logWatering(plantId, user.userId, dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':plantId')
|
||||||
|
async updateSchedule(
|
||||||
|
@Param('plantId') plantId: string,
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Body() dto: UpdateScheduleDto
|
||||||
|
) {
|
||||||
|
return this.wateringService.updateSchedule(plantId, user.userId, dto.frequencyDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':plantId/history')
|
||||||
|
async getHistory(@Param('plantId') plantId: string, @CurrentUser() user: CurrentUserData) {
|
||||||
|
return this.wateringService.getWateringHistory(plantId, user.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/planta/apps/backend/src/watering/watering.module.ts
Normal file
10
apps/planta/apps/backend/src/watering/watering.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WateringController } from './watering.controller';
|
||||||
|
import { WateringService } from './watering.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WateringController],
|
||||||
|
providers: [WateringService],
|
||||||
|
exports: [WateringService],
|
||||||
|
})
|
||||||
|
export class WateringModule {}
|
||||||
169
apps/planta/apps/backend/src/watering/watering.service.ts
Normal file
169
apps/planta/apps/backend/src/watering/watering.service.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||||
|
import { eq, and, lte, desc } from 'drizzle-orm';
|
||||||
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
import { wateringSchedules, wateringLogs, plants } from '../db/schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WateringService {
|
||||||
|
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||||
|
|
||||||
|
async getUpcoming(userId: string) {
|
||||||
|
const now = new Date();
|
||||||
|
const threeDaysFromNow = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const schedules = await this.db
|
||||||
|
.select({
|
||||||
|
schedule: wateringSchedules,
|
||||||
|
plant: plants,
|
||||||
|
})
|
||||||
|
.from(wateringSchedules)
|
||||||
|
.innerJoin(plants, eq(wateringSchedules.plantId, plants.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(wateringSchedules.userId, userId),
|
||||||
|
lte(wateringSchedules.nextWateringAt, threeDaysFromNow)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(wateringSchedules.nextWateringAt);
|
||||||
|
|
||||||
|
return schedules.map(({ schedule, plant }) => {
|
||||||
|
const nextWatering = schedule.nextWateringAt ? new Date(schedule.nextWateringAt) : null;
|
||||||
|
const daysUntil = nextWatering
|
||||||
|
? Math.ceil((nextWatering.getTime() - now.getTime()) / (24 * 60 * 60 * 1000))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
plantId: plant.id,
|
||||||
|
plantName: plant.name,
|
||||||
|
daysUntilWatering: daysUntil,
|
||||||
|
isOverdue: daysUntil < 0,
|
||||||
|
lastWateredAt: schedule.lastWateredAt,
|
||||||
|
nextWateringAt: schedule.nextWateringAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async logWatering(plantId: string, userId: string, notes?: string) {
|
||||||
|
// Verify plant ownership
|
||||||
|
const [plant] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plants)
|
||||||
|
.where(and(eq(plants.id, plantId), eq(plants.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!plant) {
|
||||||
|
throw new NotFoundException('Plant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Create watering log
|
||||||
|
const [log] = await this.db
|
||||||
|
.insert(wateringLogs)
|
||||||
|
.values({
|
||||||
|
plantId,
|
||||||
|
userId,
|
||||||
|
wateredAt: now,
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Update or create watering schedule
|
||||||
|
const [existingSchedule] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(wateringSchedules)
|
||||||
|
.where(eq(wateringSchedules.plantId, plantId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const frequencyDays = existingSchedule?.frequencyDays || plant.wateringFrequencyDays || 7;
|
||||||
|
const nextWateringAt = new Date(now.getTime() + frequencyDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (existingSchedule) {
|
||||||
|
await this.db
|
||||||
|
.update(wateringSchedules)
|
||||||
|
.set({
|
||||||
|
lastWateredAt: now,
|
||||||
|
nextWateringAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(wateringSchedules.id, existingSchedule.id));
|
||||||
|
} else {
|
||||||
|
await this.db.insert(wateringSchedules).values({
|
||||||
|
plantId,
|
||||||
|
userId,
|
||||||
|
frequencyDays,
|
||||||
|
lastWateredAt: now,
|
||||||
|
nextWateringAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
log,
|
||||||
|
nextWateringAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSchedule(plantId: string, userId: string, frequencyDays: number) {
|
||||||
|
// Verify plant ownership
|
||||||
|
const [plant] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plants)
|
||||||
|
.where(and(eq(plants.id, plantId), eq(plants.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!plant) {
|
||||||
|
throw new NotFoundException('Plant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingSchedule] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(wateringSchedules)
|
||||||
|
.where(eq(wateringSchedules.plantId, plantId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (existingSchedule) {
|
||||||
|
const nextWateringAt = existingSchedule.lastWateredAt
|
||||||
|
? new Date(
|
||||||
|
new Date(existingSchedule.lastWateredAt).getTime() + frequencyDays * 24 * 60 * 60 * 1000
|
||||||
|
)
|
||||||
|
: new Date(now.getTime() + frequencyDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.update(wateringSchedules)
|
||||||
|
.set({
|
||||||
|
frequencyDays,
|
||||||
|
nextWateringAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(wateringSchedules.id, existingSchedule.id));
|
||||||
|
|
||||||
|
return { success: true, nextWateringAt };
|
||||||
|
} else {
|
||||||
|
const nextWateringAt = new Date(now.getTime() + frequencyDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await this.db.insert(wateringSchedules).values({
|
||||||
|
plantId,
|
||||||
|
userId,
|
||||||
|
frequencyDays,
|
||||||
|
nextWateringAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, nextWateringAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWateringHistory(plantId: string, userId: string) {
|
||||||
|
const logs = await this.db
|
||||||
|
.select()
|
||||||
|
.from(wateringLogs)
|
||||||
|
.where(and(eq(wateringLogs.plantId, plantId), eq(wateringLogs.userId, userId)))
|
||||||
|
.orderBy(desc(wateringLogs.wateredAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/planta/apps/backend/tsconfig.json
Normal file
25
apps/planta/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
43
apps/planta/apps/web/package.json
Normal file
43
apps/planta/apps/web/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"name": "@planta/web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.1.7",
|
||||||
|
"tslib": "^2.4.1",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@manacore/shared-auth": "workspace:*",
|
||||||
|
"@manacore/shared-auth-ui": "workspace:*",
|
||||||
|
"@manacore/shared-branding": "workspace:*",
|
||||||
|
"@manacore/shared-i18n": "workspace:*",
|
||||||
|
"@manacore/shared-icons": "workspace:*",
|
||||||
|
"@manacore/shared-tailwind": "workspace:*",
|
||||||
|
"@manacore/shared-theme": "workspace:*",
|
||||||
|
"@manacore/shared-theme-ui": "workspace:*",
|
||||||
|
"@manacore/shared-ui": "workspace:*",
|
||||||
|
"@planta/shared": "workspace:*",
|
||||||
|
"svelte-i18n": "^4.0.1"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
190
apps/planta/apps/web/src/app.css
Normal file
190
apps/planta/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "@manacore/shared-tailwind/themes.css";
|
||||||
|
|
||||||
|
/* Scan shared packages for Tailwind classes */
|
||||||
|
@source "../../../packages/shared-ui/src";
|
||||||
|
@source "../../../packages/shared-theme-ui/src";
|
||||||
|
@source "../../../packages/shared-theme-ui/src/components";
|
||||||
|
|
||||||
|
/* Planta-specific CSS Variables */
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
--spacing-2xl: 3rem;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-xl: 1rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
--transition-slow: 300ms ease;
|
||||||
|
|
||||||
|
/* Planta primary color (green) */
|
||||||
|
--planta-primary: 142 76% 36%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
@layer components {
|
||||||
|
/* Card styles */
|
||||||
|
.card {
|
||||||
|
background-color: hsl(var(--card));
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plant card specific */
|
||||||
|
.plant-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
.btn {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: hsl(var(--primary) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: hsl(142 76% 36%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: hsl(142 76% 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input styles */
|
||||||
|
.input {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 2px solid hsl(var(--border));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Watering status indicators */
|
||||||
|
.water-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-status.overdue {
|
||||||
|
color: hsl(0 84% 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-status.soon {
|
||||||
|
color: hsl(38 92% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-status.ok {
|
||||||
|
color: hsl(142 76% 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload zone */
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed hsl(var(--border));
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-2xl);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover,
|
||||||
|
.upload-zone.dragover {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Health status badge */
|
||||||
|
.health-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-badge.healthy {
|
||||||
|
background: hsl(142 76% 36% / 0.1);
|
||||||
|
color: hsl(142 76% 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-badge.needs_attention {
|
||||||
|
background: hsl(38 92% 50% / 0.1);
|
||||||
|
color: hsl(38 92% 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-badge.sick {
|
||||||
|
background: hsl(0 84% 60% / 0.1);
|
||||||
|
color: hsl(0 84% 50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy color variable compatibility */
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-surface: var(--card);
|
||||||
|
--color-text-primary: var(--foreground);
|
||||||
|
--color-text-secondary: var(--muted-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-success: 142 76% 36%;
|
||||||
|
--color-warning: 38 92% 50%;
|
||||||
|
--color-error: 0 84% 60%;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/planta/apps/web/src/app.html
Normal file
13
apps/planta/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Planta - Pflanzendokumentation</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
apps/planta/apps/web/src/lib/api/analysis.ts
Normal file
30
apps/planta/apps/web/src/lib/api/analysis.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Analysis API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchApi } from './client';
|
||||||
|
import type { PlantAnalysis } from '@planta/shared';
|
||||||
|
|
||||||
|
export const analysisApi = {
|
||||||
|
async analyze(photoId: string, plantId?: string): Promise<PlantAnalysis | null> {
|
||||||
|
const { data, error } = await fetchApi<PlantAnalysis>('/analysis/identify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { photoId, plantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to analyze photo:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getByPhotoId(photoId: string): Promise<PlantAnalysis | null> {
|
||||||
|
const { data, error } = await fetchApi<PlantAnalysis>(`/analysis/${photoId}`);
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to fetch analysis:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
62
apps/planta/apps/web/src/lib/api/client.ts
Normal file
62
apps/planta/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* API Client for Planta backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
function getBackendUrl(): string {
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||||
|
.__PUBLIC_BACKEND_URL__;
|
||||||
|
return injectedUrl || 'http://localhost:3022';
|
||||||
|
}
|
||||||
|
return 'http://localhost:3022';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchApi<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
body?: unknown;
|
||||||
|
formData?: FormData;
|
||||||
|
} = {}
|
||||||
|
): Promise<{ data: T | null; error: string | null }> {
|
||||||
|
const token = await authStore.getValidToken();
|
||||||
|
if (!token) {
|
||||||
|
return { data: null, error: 'Not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't set Content-Type for FormData - browser will set it with boundary
|
||||||
|
if (!options.formData) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getBackendUrl()}/api/v1${endpoint}`, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.formData || (options.body ? JSON.stringify(options.body) : undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: errorData.message || `API error: ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/planta/apps/web/src/lib/api/photos.ts
Normal file
36
apps/planta/apps/web/src/lib/api/photos.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Photos API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchApi } from './client';
|
||||||
|
import type { PlantPhoto } from '@planta/shared';
|
||||||
|
|
||||||
|
export const photosApi = {
|
||||||
|
async upload(file: File, plantId?: string): Promise<PlantPhoto | null> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const endpoint = plantId ? `/photos/upload?plantId=${plantId}` : '/photos/upload';
|
||||||
|
|
||||||
|
const { data, error } = await fetchApi<PlantPhoto>(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to upload photo:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const { error } = await fetchApi(`/photos/${id}`, { method: 'DELETE' });
|
||||||
|
return !error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setPrimary(id: string): Promise<boolean> {
|
||||||
|
const { error } = await fetchApi(`/photos/${id}/primary`, { method: 'PUT' });
|
||||||
|
return !error;
|
||||||
|
},
|
||||||
|
};
|
||||||
55
apps/planta/apps/web/src/lib/api/plants.ts
Normal file
55
apps/planta/apps/web/src/lib/api/plants.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Plants API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchApi } from './client';
|
||||||
|
import type { Plant, PlantWithDetails, CreatePlantDto, UpdatePlantDto } from '@planta/shared';
|
||||||
|
|
||||||
|
export const plantsApi = {
|
||||||
|
async getAll(): Promise<Plant[]> {
|
||||||
|
const { data, error } = await fetchApi<Plant[]>('/plants');
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to fetch plants:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return data || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string): Promise<PlantWithDetails | null> {
|
||||||
|
const { data, error } = await fetchApi<PlantWithDetails>(`/plants/${id}`);
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to fetch plant:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(dto: CreatePlantDto): Promise<Plant | null> {
|
||||||
|
const { data, error } = await fetchApi<Plant>('/plants', {
|
||||||
|
method: 'POST',
|
||||||
|
body: dto,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to create plant:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePlantDto): Promise<Plant | null> {
|
||||||
|
const { data, error } = await fetchApi<Plant>(`/plants/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: dto,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to update plant:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const { error } = await fetchApi(`/plants/${id}`, { method: 'DELETE' });
|
||||||
|
return !error;
|
||||||
|
},
|
||||||
|
};
|
||||||
42
apps/planta/apps/web/src/lib/api/watering.ts
Normal file
42
apps/planta/apps/web/src/lib/api/watering.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Watering API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchApi } from './client';
|
||||||
|
import type { WateringStatus, WateringLog } from '@planta/shared';
|
||||||
|
|
||||||
|
export const wateringApi = {
|
||||||
|
async getUpcoming(): Promise<WateringStatus[]> {
|
||||||
|
const { data, error } = await fetchApi<WateringStatus[]>('/watering/upcoming');
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to fetch upcoming watering:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return data || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async logWatering(plantId: string, notes?: string): Promise<boolean> {
|
||||||
|
const { error } = await fetchApi(`/watering/${plantId}/water`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { notes },
|
||||||
|
});
|
||||||
|
return !error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSchedule(plantId: string, frequencyDays: number): Promise<boolean> {
|
||||||
|
const { error } = await fetchApi(`/watering/${plantId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { frequencyDays },
|
||||||
|
});
|
||||||
|
return !error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHistory(plantId: string): Promise<WateringLog[]> {
|
||||||
|
const { data, error } = await fetchApi<WateringLog[]>(`/watering/${plantId}/history`);
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to fetch watering history:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return data || [];
|
||||||
|
},
|
||||||
|
};
|
||||||
169
apps/planta/apps/web/src/lib/stores/auth.svelte.ts
Normal file
169
apps/planta/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/**
|
||||||
|
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||||
|
* Uses Mana Core Auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||||
|
import type { UserData } from '@manacore/shared-auth';
|
||||||
|
|
||||||
|
// Get auth URL dynamically at runtime
|
||||||
|
function getAuthUrl(): string {
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||||
|
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||||
|
return injectedUrl || 'http://localhost:3001';
|
||||||
|
}
|
||||||
|
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backend URL dynamically at runtime
|
||||||
|
function getBackendUrl(): string {
|
||||||
|
if (browser && typeof window !== 'undefined') {
|
||||||
|
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||||
|
.__PUBLIC_BACKEND_URL__;
|
||||||
|
return injectedUrl || 'http://localhost:3022';
|
||||||
|
}
|
||||||
|
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3022';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy initialization to avoid SSR issues
|
||||||
|
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||||
|
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||||
|
|
||||||
|
function getAuthService() {
|
||||||
|
if (!browser) return null;
|
||||||
|
if (!_authService) {
|
||||||
|
const auth = initializeWebAuth({
|
||||||
|
baseUrl: getAuthUrl(),
|
||||||
|
backendUrl: getBackendUrl(),
|
||||||
|
});
|
||||||
|
_authService = auth.authService;
|
||||||
|
_tokenManager = auth.tokenManager;
|
||||||
|
}
|
||||||
|
return _authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenManager() {
|
||||||
|
if (!browser) return null;
|
||||||
|
getAuthService();
|
||||||
|
return _tokenManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
let user = $state<UserData | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let initialized = $state(false);
|
||||||
|
|
||||||
|
export const authStore = {
|
||||||
|
get user() {
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
get loading() {
|
||||||
|
return loading;
|
||||||
|
},
|
||||||
|
get isAuthenticated() {
|
||||||
|
return !!user;
|
||||||
|
},
|
||||||
|
get initialized() {
|
||||||
|
return initialized;
|
||||||
|
},
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
if (initialized) return;
|
||||||
|
|
||||||
|
const authService = getAuthService();
|
||||||
|
if (!authService) {
|
||||||
|
initialized = true;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const authenticated = await authService.isAuthenticated();
|
||||||
|
if (authenticated) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = userData;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize auth:', error);
|
||||||
|
user = null;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async signIn(email: string, password: string) {
|
||||||
|
const authService = getAuthService();
|
||||||
|
if (!authService) {
|
||||||
|
return { success: false, error: 'Auth not available on server' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authService.signIn(email, password);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, error: result.error || 'Login failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = userData;
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async signUp(email: string, password: string) {
|
||||||
|
const authService = getAuthService();
|
||||||
|
if (!authService) {
|
||||||
|
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authService.signUp(email, password);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.needsVerification) {
|
||||||
|
return { success: true, needsVerification: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signInResult = await this.signIn(email, password);
|
||||||
|
return { ...signInResult, needsVerification: false };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
return { success: false, error: errorMessage, needsVerification: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async signOut() {
|
||||||
|
const authService = getAuthService();
|
||||||
|
if (!authService) {
|
||||||
|
user = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.signOut();
|
||||||
|
user = null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign out error:', error);
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getValidToken(): Promise<string | null> {
|
||||||
|
const tokenManager = getTokenManager();
|
||||||
|
if (!tokenManager) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await tokenManager.getValidToken();
|
||||||
|
},
|
||||||
|
};
|
||||||
7
apps/planta/apps/web/src/lib/stores/theme.ts
Normal file
7
apps/planta/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Theme Store - Simple theme management using @manacore/shared-theme
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createThemeStore } from '@manacore/shared-theme';
|
||||||
|
|
||||||
|
export const theme = createThemeStore('planta');
|
||||||
90
apps/planta/apps/web/src/routes/(app)/+layout.svelte
Normal file
90
apps/planta/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { PillNavigation } from '@manacore/shared-ui';
|
||||||
|
import type { PillNavItem } from '@manacore/shared-ui';
|
||||||
|
import { theme } from '$lib/stores/theme';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
// Navigation items for Planta
|
||||||
|
const navItems: PillNavItem[] = [
|
||||||
|
{ href: '/dashboard', label: 'Meine Pflanzen', icon: 'document' },
|
||||||
|
{ href: '/add', label: 'Hinzufügen', icon: 'plus' },
|
||||||
|
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let isDark = $derived(theme.isDark);
|
||||||
|
|
||||||
|
function handleToggleTheme() {
|
||||||
|
theme.toggleMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authStore.signOut();
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if authStore.isAuthenticated}
|
||||||
|
<div class="layout-container">
|
||||||
|
<PillNavigation
|
||||||
|
items={navItems}
|
||||||
|
currentPath={$page.url.pathname}
|
||||||
|
appName="Planta"
|
||||||
|
homeRoute="/dashboard"
|
||||||
|
onToggleTheme={handleToggleTheme}
|
||||||
|
{isDark}
|
||||||
|
showLogout={true}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
loginHref="/login"
|
||||||
|
primaryColor="#10b981"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main class="main-content pt-24">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-h-screen items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layout-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
max-width: 80rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.content-wrapper {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
276
apps/planta/apps/web/src/routes/(app)/add/+page.svelte
Normal file
276
apps/planta/apps/web/src/routes/(app)/add/+page.svelte
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { photosApi } from '$lib/api/photos';
|
||||||
|
import { analysisApi } from '$lib/api/analysis';
|
||||||
|
import { plantsApi } from '$lib/api/plants';
|
||||||
|
import type { PlantPhoto, PlantAnalysis } from '@planta/shared';
|
||||||
|
|
||||||
|
let step = $state<'upload' | 'analyzing' | 'result'>('upload');
|
||||||
|
let dragover = $state(false);
|
||||||
|
let photo = $state<PlantPhoto | null>(null);
|
||||||
|
let analysis = $state<PlantAnalysis | null>(null);
|
||||||
|
let plantName = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragover = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
dragover = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragover = false;
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
await uploadFile(files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
await uploadFile(input.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file: File) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
error = 'Bitte wähle ein Bild aus';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = '';
|
||||||
|
step = 'analyzing';
|
||||||
|
|
||||||
|
// Upload photo
|
||||||
|
const uploadedPhoto = await photosApi.upload(file);
|
||||||
|
if (!uploadedPhoto) {
|
||||||
|
error = 'Foto konnte nicht hochgeladen werden';
|
||||||
|
step = 'upload';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
photo = uploadedPhoto;
|
||||||
|
|
||||||
|
// Analyze with AI
|
||||||
|
const analysisResult = await analysisApi.analyze(uploadedPhoto.id);
|
||||||
|
if (!analysisResult) {
|
||||||
|
error = 'Analyse fehlgeschlagen. Bitte versuche es erneut.';
|
||||||
|
step = 'upload';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis = analysisResult;
|
||||||
|
|
||||||
|
// Set default plant name from analysis
|
||||||
|
if (analysisResult.commonNames && analysisResult.commonNames.length > 0) {
|
||||||
|
plantName = analysisResult.commonNames[0];
|
||||||
|
} else if (analysisResult.scientificName) {
|
||||||
|
plantName = analysisResult.scientificName;
|
||||||
|
}
|
||||||
|
|
||||||
|
step = 'result';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePlant() {
|
||||||
|
if (!plantName.trim()) {
|
||||||
|
error = 'Bitte gib einen Namen für die Pflanze ein';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!photo || !analysis) {
|
||||||
|
error = 'Keine Analyse vorhanden';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Create plant
|
||||||
|
const plant = await plantsApi.create({
|
||||||
|
name: plantName.trim(),
|
||||||
|
scientificName: analysis.scientificName || undefined,
|
||||||
|
commonName: analysis.commonNames?.[0] || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plant) {
|
||||||
|
error = 'Pflanze konnte nicht gespeichert werden';
|
||||||
|
saving = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to plant detail
|
||||||
|
goto(`/plants/${plant.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHealthBadgeClass(status: string | null | undefined): string {
|
||||||
|
if (!status) return 'healthy';
|
||||||
|
if (status === 'minor_issues' || status === 'needs_care') return 'needs_attention';
|
||||||
|
if (status === 'critical') return 'sick';
|
||||||
|
return 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHealthText(status: string | null | undefined): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
healthy: 'Gesund',
|
||||||
|
minor_issues: 'Kleine Probleme',
|
||||||
|
needs_care: 'Braucht Pflege',
|
||||||
|
critical: 'Kritisch',
|
||||||
|
};
|
||||||
|
return map[status || ''] || 'Gesund';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Pflanze hinzufügen - Planta</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold">Pflanze hinzufügen</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if step === 'upload'}
|
||||||
|
<div
|
||||||
|
class="upload-zone"
|
||||||
|
class:dragover
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
onclick={() => document.getElementById('file-input')?.click()}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && document.getElementById('file-input')?.click()}
|
||||||
|
>
|
||||||
|
<div class="text-6xl mb-4">📷</div>
|
||||||
|
<p class="text-lg font-medium">Foto hochladen</p>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
|
Ziehe ein Bild hierher oder klicke zum Auswählen
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-muted-foreground">
|
||||||
|
Die KI analysiert dein Foto und erstellt automatisch einen Pflanzensteckbrief.
|
||||||
|
</p>
|
||||||
|
{:else if step === 'analyzing'}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div
|
||||||
|
class="h-16 w-16 mx-auto mb-4 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||||
|
></div>
|
||||||
|
<p class="text-lg font-medium">Pflanze wird analysiert...</p>
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
|
Die KI identifiziert deine Pflanze und erstellt Pflegeempfehlungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'result' && analysis}
|
||||||
|
<div class="card">
|
||||||
|
{#if photo?.publicUrl}
|
||||||
|
<img
|
||||||
|
src={photo.publicUrl}
|
||||||
|
alt="Pflanzenfoto"
|
||||||
|
class="w-full h-64 object-cover rounded-lg mb-4"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Identification -->
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-lg">
|
||||||
|
{analysis.scientificName || 'Unbekannte Pflanze'}
|
||||||
|
</h3>
|
||||||
|
{#if analysis.commonNames && analysis.commonNames.length > 0}
|
||||||
|
<p class="text-muted-foreground">{analysis.commonNames.join(', ')}</p>
|
||||||
|
{/if}
|
||||||
|
{#if analysis.confidence}
|
||||||
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
|
Sicherheit: {analysis.confidence}%
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Health Status -->
|
||||||
|
<div>
|
||||||
|
<span class="health-badge {getHealthBadgeClass(analysis.healthAssessment)}">
|
||||||
|
{getHealthText(analysis.healthAssessment)}
|
||||||
|
</span>
|
||||||
|
{#if analysis.healthDetails}
|
||||||
|
<p class="text-sm text-muted-foreground mt-2">{analysis.healthDetails}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Care Recommendations -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
{#if analysis.lightAdvice}
|
||||||
|
<div class="p-3 bg-muted rounded-lg">
|
||||||
|
<p class="text-sm font-medium">☀️ Licht</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{analysis.lightAdvice}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if analysis.wateringAdvice}
|
||||||
|
<div class="p-3 bg-muted rounded-lg">
|
||||||
|
<p class="text-sm font-medium">💧 Gießen</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{analysis.wateringAdvice}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Care Tips -->
|
||||||
|
{#if analysis.generalTips && analysis.generalTips.length > 0}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium mb-2">Pflegetipps</p>
|
||||||
|
<ul class="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||||
|
{#each analysis.generalTips as tip}
|
||||||
|
<li>{tip}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Save Form -->
|
||||||
|
<div class="border-t pt-4 mt-4">
|
||||||
|
<label for="plant-name" class="block text-sm font-medium mb-2">
|
||||||
|
Name deiner Pflanze
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="plant-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={plantName}
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="z.B. Meine Monstera"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success w-full mt-4"
|
||||||
|
onclick={savePlant}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{#if saving}
|
||||||
|
<span
|
||||||
|
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
|
||||||
|
></span>
|
||||||
|
{:else}
|
||||||
|
Pflanze speichern
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
133
apps/planta/apps/web/src/routes/(app)/dashboard/+page.svelte
Normal file
133
apps/planta/apps/web/src/routes/(app)/dashboard/+page.svelte
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { plantsApi } from '$lib/api/plants';
|
||||||
|
import { wateringApi } from '$lib/api/watering';
|
||||||
|
import type { Plant, WateringStatus } from '@planta/shared';
|
||||||
|
|
||||||
|
let plants = $state<Plant[]>([]);
|
||||||
|
let wateringStatus = $state<WateringStatus[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const [plantsData, wateringData] = await Promise.all([
|
||||||
|
plantsApi.getAll(),
|
||||||
|
wateringApi.getUpcoming(),
|
||||||
|
]);
|
||||||
|
plants = plantsData;
|
||||||
|
wateringStatus = wateringData;
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getWateringStatusForPlant(plantId: string): WateringStatus | undefined {
|
||||||
|
return wateringStatus.find((s) => s.plantId === plantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWateringClass(status: WateringStatus | undefined): string {
|
||||||
|
if (!status) return '';
|
||||||
|
if (status.isOverdue) return 'overdue';
|
||||||
|
if (status.daysUntilWatering <= 1) return 'soon';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWateringText(status: WateringStatus | undefined): string {
|
||||||
|
if (!status) return '';
|
||||||
|
if (status.isOverdue) return 'Überfällig!';
|
||||||
|
if (status.daysUntilWatering === 0) return 'Heute gießen';
|
||||||
|
if (status.daysUntilWatering === 1) return 'Morgen gießen';
|
||||||
|
return `In ${status.daysUntilWatering} Tagen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWater(plantId: string, e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const success = await wateringApi.logWatering(plantId);
|
||||||
|
if (success) {
|
||||||
|
// Refresh watering status
|
||||||
|
wateringStatus = await wateringApi.getUpcoming();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Meine Pflanzen - Planta</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Meine Pflanzen</h1>
|
||||||
|
<a href="/add" class="btn btn-success"> + Pflanze hinzufügen </a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-12">
|
||||||
|
<div
|
||||||
|
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{:else if plants.length === 0}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-6xl mb-4">🌱</div>
|
||||||
|
<h2 class="text-xl font-semibold mb-2">Noch keine Pflanzen</h2>
|
||||||
|
<p class="text-muted-foreground mb-4">
|
||||||
|
Füge deine erste Pflanze hinzu und lass sie von der KI analysieren.
|
||||||
|
</p>
|
||||||
|
<a href="/add" class="btn btn-success"> Erste Pflanze hinzufügen </a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{#each plants as plant (plant.id)}
|
||||||
|
{@const status = getWateringStatusForPlant(plant.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card plant-card cursor-pointer text-left"
|
||||||
|
onclick={() => goto(`/plants/${plant.id}`)}
|
||||||
|
>
|
||||||
|
{#if plant.primaryPhoto?.publicUrl}
|
||||||
|
<img src={plant.primaryPhoto.publicUrl} alt={plant.name} />
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full w-full items-center justify-center bg-muted text-4xl">🌿</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold text-white truncate">{plant.name}</h3>
|
||||||
|
{#if plant.commonName}
|
||||||
|
<p class="text-xs text-white/70 truncate">{plant.commonName}</p>
|
||||||
|
{/if}
|
||||||
|
{#if status}
|
||||||
|
<div class="water-status {getWateringClass(status)} mt-1">
|
||||||
|
<span>💧</span>
|
||||||
|
<span>{getWateringText(status)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if status && (status.isOverdue || status.daysUntilWatering <= 1)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute top-2 right-2 rounded-full bg-blue-500 p-2 text-white hover:bg-blue-600"
|
||||||
|
onclick={(e) => handleWater(plant.id, e)}
|
||||||
|
title="Als gegossen markieren"
|
||||||
|
>
|
||||||
|
💧
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.plant-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
228
apps/planta/apps/web/src/routes/(app)/plants/[id]/+page.svelte
Normal file
228
apps/planta/apps/web/src/routes/(app)/plants/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { plantsApi } from '$lib/api/plants';
|
||||||
|
import { wateringApi } from '$lib/api/watering';
|
||||||
|
import type { PlantWithDetails, WateringLog } from '@planta/shared';
|
||||||
|
|
||||||
|
let plant = $state<PlantWithDetails | null>(null);
|
||||||
|
let wateringHistory = $state<WateringLog[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let watering = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const plantId = $page.params.id;
|
||||||
|
if (plantId) {
|
||||||
|
loadPlant(plantId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPlant(id: string) {
|
||||||
|
loading = true;
|
||||||
|
const [plantData, historyData] = await Promise.all([
|
||||||
|
plantsApi.getById(id),
|
||||||
|
wateringApi.getHistory(id),
|
||||||
|
]);
|
||||||
|
plant = plantData;
|
||||||
|
wateringHistory = historyData;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWater() {
|
||||||
|
if (!plant) return;
|
||||||
|
watering = true;
|
||||||
|
const success = await wateringApi.logWatering(plant.id);
|
||||||
|
if (success) {
|
||||||
|
// Reload plant data
|
||||||
|
await loadPlant(plant.id);
|
||||||
|
}
|
||||||
|
watering = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!plant) return;
|
||||||
|
if (!confirm(`Möchtest du "${plant.name}" wirklich löschen?`)) return;
|
||||||
|
|
||||||
|
const success = await plantsApi.delete(plant.id);
|
||||||
|
if (success) {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date | string | undefined | null): string {
|
||||||
|
if (!date) return '-';
|
||||||
|
return new Date(date).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHealthBadgeClass(status: string | null | undefined): string {
|
||||||
|
if (!status) return 'healthy';
|
||||||
|
if (status === 'needs_attention') return 'needs_attention';
|
||||||
|
if (status === 'sick') return 'sick';
|
||||||
|
return 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHealthText(status: string | null | undefined): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
healthy: 'Gesund',
|
||||||
|
needs_attention: 'Braucht Aufmerksamkeit',
|
||||||
|
sick: 'Krank',
|
||||||
|
};
|
||||||
|
return map[status || ''] || 'Gesund';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLightText(light: string | null | undefined): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
low: 'Wenig Licht',
|
||||||
|
medium: 'Mittleres Licht',
|
||||||
|
bright: 'Helles Licht',
|
||||||
|
direct: 'Direkte Sonne',
|
||||||
|
};
|
||||||
|
return map[light || ''] || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHumidityText(humidity: string | null | undefined): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
low: 'Niedrig',
|
||||||
|
medium: 'Mittel',
|
||||||
|
high: 'Hoch',
|
||||||
|
};
|
||||||
|
return map[humidity || ''] || '-';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{plant?.name || 'Pflanze'} - Planta</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-12">
|
||||||
|
<div
|
||||||
|
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{:else if !plant}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<p class="text-lg">Pflanze nicht gefunden</p>
|
||||||
|
<a href="/dashboard" class="btn btn-primary mt-4">Zurück zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{plant.name}</h1>
|
||||||
|
{#if plant.scientificName}
|
||||||
|
<p class="text-muted-foreground italic">{plant.scientificName}</p>
|
||||||
|
{/if}
|
||||||
|
{#if plant.commonName && plant.commonName !== plant.name}
|
||||||
|
<p class="text-muted-foreground">{plant.commonName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="health-badge {getHealthBadgeClass(plant.healthStatus)}">
|
||||||
|
{getHealthText(plant.healthStatus)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo Gallery -->
|
||||||
|
{#if plant.photos && plant.photos.length > 0}
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each plant.photos as photo (photo.id)}
|
||||||
|
<img
|
||||||
|
src={photo.publicUrl}
|
||||||
|
alt={plant.name}
|
||||||
|
class="w-full aspect-square object-cover rounded-lg"
|
||||||
|
class:ring-2={photo.isPrimary}
|
||||||
|
class:ring-primary={photo.isPrimary}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Care Info -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="font-semibold mb-4">Pflege</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Licht</p>
|
||||||
|
<p class="font-medium">☀️ {getLightText(plant.lightRequirements)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Gießen</p>
|
||||||
|
<p class="font-medium">
|
||||||
|
💧 {plant.wateringFrequencyDays ? `Alle ${plant.wateringFrequencyDays} Tage` : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Luftfeuchtigkeit</p>
|
||||||
|
<p class="font-medium">💨 {getHumidityText(plant.humidity)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Temperatur</p>
|
||||||
|
<p class="font-medium">🌡️ {plant.temperature || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if plant.careNotes}
|
||||||
|
<div class="mt-4 pt-4 border-t">
|
||||||
|
<p class="text-sm text-muted-foreground mb-1">Pflegehinweise</p>
|
||||||
|
<p class="text-sm whitespace-pre-line">{plant.careNotes}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Watering Schedule -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="font-semibold">Gießplan</h2>
|
||||||
|
<button type="button" class="btn btn-success" onclick={handleWater} disabled={watering}>
|
||||||
|
{#if watering}
|
||||||
|
<span
|
||||||
|
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
|
||||||
|
></span>
|
||||||
|
{:else}
|
||||||
|
💧 Jetzt gießen
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if plant.wateringSchedule}
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Zuletzt gegossen</p>
|
||||||
|
<p class="font-medium">{formatDate(plant.wateringSchedule.lastWateredAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Nächstes Gießen</p>
|
||||||
|
<p class="font-medium">{formatDate(plant.wateringSchedule.nextWateringAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if wateringHistory.length > 0}
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<p class="text-sm text-muted-foreground mb-2">Letzte Gießvorgänge</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each wateringHistory.slice(0, 5) as log (log.id)}
|
||||||
|
<li class="text-sm flex justify-between">
|
||||||
|
<span>💧 Gegossen</span>
|
||||||
|
<span class="text-muted-foreground">{formatDate(log.wateredAt)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<a href="/dashboard" class="btn flex-1 bg-muted text-foreground"> ← Zurück </a>
|
||||||
|
<button type="button" class="btn bg-destructive text-white" onclick={handleDelete}>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
59
apps/planta/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
59
apps/planta/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { theme } from '$lib/stores/theme';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
let isDark = $derived(theme.isDark);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Einstellungen - Planta</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-xl space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold">Einstellungen</h1>
|
||||||
|
|
||||||
|
<!-- Theme -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="font-semibold mb-4">Darstellung</h2>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Dunkles Design</p>
|
||||||
|
<p class="text-sm text-muted-foreground">Wechsle zwischen hellem und dunklem Modus</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
class:bg-primary={isDark}
|
||||||
|
class:bg-muted={!isDark}
|
||||||
|
onclick={() => theme.toggleMode()}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
class:translate-x-6={isDark}
|
||||||
|
class:translate-x-1={!isDark}
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="font-semibold mb-4">Konto</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">E-Mail</p>
|
||||||
|
<p class="font-medium">{authStore.user?.email || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="font-semibold mb-4">Über Planta</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Planta hilft dir, deine Pflanzen zu dokumentieren und zu pflegen. Mache ein Foto und die KI
|
||||||
|
erstellt automatisch einen Steckbrief mit Pflegehinweisen und Gießvorschlägen.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted-foreground mt-2">Version 1.0.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
13
apps/planta/apps/web/src/routes/(auth)/+layout.svelte
Normal file
13
apps/planta/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<h1 class="text-3xl font-bold text-foreground">Planta</h1>
|
||||||
|
<p class="mt-2 text-muted-foreground">Deine Pflanzen dokumentieren</p>
|
||||||
|
</div>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
72
apps/planta/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
72
apps/planta/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
const result = await authStore.signIn(email, password);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
goto('/dashboard');
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Login fehlgeschlagen';
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-foreground">E-Mail</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
class="input mt-1 w-full"
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-foreground">Passwort</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
class="input mt-1 w-full"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
|
||||||
|
{#if loading}
|
||||||
|
<span
|
||||||
|
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
|
||||||
|
></span>
|
||||||
|
{:else}
|
||||||
|
Anmelden
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-muted-foreground">
|
||||||
|
Noch kein Konto?
|
||||||
|
<a href="/register" class="text-primary hover:underline">Registrieren</a>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
103
apps/planta/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
103
apps/planta/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let passwordConfirm = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
error = 'Passwörter stimmen nicht überein';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
error = 'Passwort muss mindestens 8 Zeichen lang sein';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
const result = await authStore.signUp(email, password);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.needsVerification) {
|
||||||
|
error = 'Bitte bestätige deine E-Mail-Adresse';
|
||||||
|
} else {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Registrierung fehlgeschlagen';
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-foreground">E-Mail</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
class="input mt-1 w-full"
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-foreground">Passwort</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
class="input mt-1 w-full"
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="passwordConfirm" class="block text-sm font-medium text-foreground">
|
||||||
|
Passwort bestätigen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="passwordConfirm"
|
||||||
|
type="password"
|
||||||
|
bind:value={passwordConfirm}
|
||||||
|
required
|
||||||
|
class="input mt-1 w-full"
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
|
||||||
|
{#if loading}
|
||||||
|
<span
|
||||||
|
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
|
||||||
|
></span>
|
||||||
|
{:else}
|
||||||
|
Registrieren
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-muted-foreground">
|
||||||
|
Bereits ein Konto?
|
||||||
|
<a href="/login" class="text-primary hover:underline">Anmelden</a>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
35
apps/planta/apps/web/src/routes/+layout.svelte
Normal file
35
apps/planta/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { theme } from '$lib/stores/theme';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Initialize theme
|
||||||
|
theme.initialize();
|
||||||
|
|
||||||
|
// Initialize auth
|
||||||
|
await authStore.initialize();
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||||
|
></div>
|
||||||
|
<p class="text-muted-foreground">Laden...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="min-h-screen bg-background text-foreground">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
19
apps/planta/apps/web/src/routes/+page.svelte
Normal file
19
apps/planta/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
goto('/dashboard');
|
||||||
|
} else {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
12
apps/planta/apps/web/svelte.config.js
Normal file
12
apps/planta/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14
apps/planta/apps/web/tsconfig.json
Normal file
14
apps/planta/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/planta/apps/web/vite.config.ts
Normal file
37
apps/planta/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
port: 5191,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
noExternal: [
|
||||||
|
'@planta/shared',
|
||||||
|
'@manacore/shared-icons',
|
||||||
|
'@manacore/shared-ui',
|
||||||
|
'@manacore/shared-tailwind',
|
||||||
|
'@manacore/shared-theme',
|
||||||
|
'@manacore/shared-theme-ui',
|
||||||
|
'@manacore/shared-auth',
|
||||||
|
'@manacore/shared-auth-ui',
|
||||||
|
'@manacore/shared-branding',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: [
|
||||||
|
'@planta/shared',
|
||||||
|
'@manacore/shared-icons',
|
||||||
|
'@manacore/shared-ui',
|
||||||
|
'@manacore/shared-tailwind',
|
||||||
|
'@manacore/shared-theme',
|
||||||
|
'@manacore/shared-theme-ui',
|
||||||
|
'@manacore/shared-auth',
|
||||||
|
'@manacore/shared-auth-ui',
|
||||||
|
'@manacore/shared-branding',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
18
apps/planta/package.json
Normal file
18
apps/planta/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "planta",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Planta - Plant Documentation & Care App",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo run dev",
|
||||||
|
"dev:backend": "pnpm --filter @planta/backend dev",
|
||||||
|
"dev:web": "pnpm --filter @planta/web dev",
|
||||||
|
"db:push": "pnpm --filter @planta/backend db:push",
|
||||||
|
"db:studio": "pnpm --filter @planta/backend db:studio",
|
||||||
|
"db:seed": "pnpm --filter @planta/backend db:seed"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.0"
|
||||||
|
}
|
||||||
18
apps/planta/packages/shared/package.json
Normal file
18
apps/planta/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "@planta/shared",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./types": "./src/types/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
1
apps/planta/packages/shared/src/index.ts
Normal file
1
apps/planta/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './types/index.js';
|
||||||
159
apps/planta/packages/shared/src/types/index.ts
Normal file
159
apps/planta/packages/shared/src/types/index.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// Light level requirements
|
||||||
|
export type LightLevel = 'low' | 'medium' | 'bright' | 'direct';
|
||||||
|
|
||||||
|
// Humidity requirements
|
||||||
|
export type HumidityLevel = 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
// Health status
|
||||||
|
export type HealthStatus = 'healthy' | 'needs_attention' | 'sick';
|
||||||
|
|
||||||
|
// Health assessment from AI
|
||||||
|
export type HealthAssessment = 'healthy' | 'minor_issues' | 'needs_care' | 'critical';
|
||||||
|
|
||||||
|
// Plant entity
|
||||||
|
export interface Plant {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
scientificName?: string;
|
||||||
|
commonName?: string;
|
||||||
|
species?: string;
|
||||||
|
lightRequirements?: LightLevel;
|
||||||
|
wateringFrequencyDays?: number;
|
||||||
|
humidity?: HumidityLevel;
|
||||||
|
temperature?: string;
|
||||||
|
soilType?: string;
|
||||||
|
careNotes?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
healthStatus?: HealthStatus;
|
||||||
|
acquiredAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plant with all related data
|
||||||
|
export interface PlantWithDetails extends Plant {
|
||||||
|
photos: PlantPhoto[];
|
||||||
|
wateringSchedule?: WateringSchedule;
|
||||||
|
latestAnalysis?: PlantAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plant photo
|
||||||
|
export interface PlantPhoto {
|
||||||
|
id: string;
|
||||||
|
plantId: string;
|
||||||
|
userId: string;
|
||||||
|
storagePath: string;
|
||||||
|
publicUrl?: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
isPrimary: boolean;
|
||||||
|
isAnalyzed: boolean;
|
||||||
|
takenAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI analysis result
|
||||||
|
export interface PlantAnalysis {
|
||||||
|
id: string;
|
||||||
|
photoId: string;
|
||||||
|
plantId?: string;
|
||||||
|
userId: string;
|
||||||
|
identifiedSpecies?: string;
|
||||||
|
scientificName?: string;
|
||||||
|
commonNames?: string[];
|
||||||
|
confidence?: number;
|
||||||
|
healthAssessment?: HealthAssessment;
|
||||||
|
healthDetails?: string;
|
||||||
|
issues?: string[];
|
||||||
|
wateringAdvice?: string;
|
||||||
|
lightAdvice?: string;
|
||||||
|
fertilizingAdvice?: string;
|
||||||
|
generalTips?: string[];
|
||||||
|
model?: string;
|
||||||
|
tokensUsed?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watering schedule
|
||||||
|
export interface WateringSchedule {
|
||||||
|
id: string;
|
||||||
|
plantId: string;
|
||||||
|
userId: string;
|
||||||
|
frequencyDays: number;
|
||||||
|
lastWateredAt?: Date;
|
||||||
|
nextWateringAt?: Date;
|
||||||
|
reminderEnabled: boolean;
|
||||||
|
reminderHoursBefore: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watering log entry
|
||||||
|
export interface WateringLog {
|
||||||
|
id: string;
|
||||||
|
plantId: string;
|
||||||
|
userId: string;
|
||||||
|
wateredAt: Date;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watering status for dashboard
|
||||||
|
export interface WateringStatus {
|
||||||
|
plantId: string;
|
||||||
|
plantName: string;
|
||||||
|
daysUntilWatering: number;
|
||||||
|
isOverdue: boolean;
|
||||||
|
lastWateredAt?: Date;
|
||||||
|
nextWateringAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs for API
|
||||||
|
export interface CreatePlantDto {
|
||||||
|
name: string;
|
||||||
|
scientificName?: string;
|
||||||
|
commonName?: string;
|
||||||
|
acquiredAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePlantDto {
|
||||||
|
name?: string;
|
||||||
|
scientificName?: string;
|
||||||
|
commonName?: string;
|
||||||
|
careNotes?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
lightRequirements?: LightLevel;
|
||||||
|
wateringFrequencyDays?: number;
|
||||||
|
humidity?: HumidityLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisRequest {
|
||||||
|
photoId: string;
|
||||||
|
plantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI analysis response structure
|
||||||
|
export interface AnalysisResult {
|
||||||
|
identification: {
|
||||||
|
scientificName: string;
|
||||||
|
commonNames: string[];
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
health: {
|
||||||
|
status: HealthAssessment;
|
||||||
|
issues: string[];
|
||||||
|
details: string;
|
||||||
|
};
|
||||||
|
care: {
|
||||||
|
light: LightLevel;
|
||||||
|
wateringFrequencyDays: number;
|
||||||
|
humidity: HumidityLevel;
|
||||||
|
temperature: string;
|
||||||
|
soilType: string;
|
||||||
|
tips: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
16
apps/planta/packages/shared/tsconfig.json
Normal file
16
apps/planta/packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
@ -85,7 +85,9 @@ services:
|
||||||
mc mb --ignore-existing myminio/contacts-storage;
|
mc mb --ignore-existing myminio/contacts-storage;
|
||||||
mc mb --ignore-existing myminio/storage-storage;
|
mc mb --ignore-existing myminio/storage-storage;
|
||||||
mc mb --ignore-existing myminio/inventory-storage;
|
mc mb --ignore-existing myminio/inventory-storage;
|
||||||
|
mc mb --ignore-existing myminio/planta-storage;
|
||||||
mc anonymous set download myminio/picture-storage;
|
mc anonymous set download myminio/picture-storage;
|
||||||
|
mc anonymous set download myminio/planta-storage;
|
||||||
mc anonymous set download myminio/inventory-storage;
|
mc anonymous set download myminio/inventory-storage;
|
||||||
echo 'Buckets created successfully';
|
echo 'Buckets created successfully';
|
||||||
exit 0;
|
exit 0;
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,14 @@
|
||||||
"dev:worldream:web": "pnpm --filter @worldream/web dev",
|
"dev:worldream:web": "pnpm --filter @worldream/web dev",
|
||||||
"context:dev": "turbo run dev --filter=context...",
|
"context:dev": "turbo run dev --filter=context...",
|
||||||
"dev:context:mobile": "pnpm --filter @context/mobile dev",
|
"dev:context:mobile": "pnpm --filter @context/mobile dev",
|
||||||
|
"planta:dev": "turbo run dev --filter=planta...",
|
||||||
|
"dev:planta:web": "pnpm --filter @planta/web dev",
|
||||||
|
"dev:planta:backend": "pnpm --filter @planta/backend dev",
|
||||||
|
"dev:planta:app": "turbo run dev --filter=@planta/web --filter=@planta/backend",
|
||||||
|
"dev:planta:full": "./scripts/setup-databases.sh planta && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:planta:backend\" \"pnpm dev:planta:web\"",
|
||||||
|
"planta:db:push": "pnpm --filter @planta/backend db:push",
|
||||||
|
"planta:db:studio": "pnpm --filter @planta/backend db:studio",
|
||||||
|
"planta:db:seed": "pnpm --filter @planta/backend db:seed",
|
||||||
"docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init",
|
"docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init",
|
||||||
"docker:up:infra": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init",
|
"docker:up:infra": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init",
|
||||||
"docker:up:db": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis",
|
"docker:up:db": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis",
|
||||||
|
|
|
||||||
211
pnpm-lock.yaml
generated
211
pnpm-lock.yaml
generated
|
|
@ -2541,6 +2541,206 @@ importers:
|
||||||
specifier: ~5.8.3
|
specifier: ~5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
|
|
||||||
|
apps/planta:
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.9.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
|
apps/planta/apps/backend:
|
||||||
|
dependencies:
|
||||||
|
'@google/generative-ai':
|
||||||
|
specifier: ^0.21.0
|
||||||
|
version: 0.21.0
|
||||||
|
'@manacore/shared-nestjs-auth':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-nestjs-auth
|
||||||
|
'@manacore/shared-storage':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-storage
|
||||||
|
'@nestjs/common':
|
||||||
|
specifier: ^10.4.15
|
||||||
|
version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/config':
|
||||||
|
specifier: ^3.3.0
|
||||||
|
version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||||
|
'@nestjs/core':
|
||||||
|
specifier: ^10.4.15
|
||||||
|
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/platform-express':
|
||||||
|
specifier: ^10.4.15
|
||||||
|
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
|
||||||
|
'@planta/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared
|
||||||
|
class-transformer:
|
||||||
|
specifier: ^0.5.1
|
||||||
|
version: 0.5.1
|
||||||
|
class-validator:
|
||||||
|
specifier: ^0.14.1
|
||||||
|
version: 0.14.3
|
||||||
|
dotenv:
|
||||||
|
specifier: ^16.4.7
|
||||||
|
version: 16.6.1
|
||||||
|
drizzle-kit:
|
||||||
|
specifier: ^0.30.2
|
||||||
|
version: 0.30.6
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: ^0.38.3
|
||||||
|
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.7)(expo-sqlite@15.2.14(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(kysely@0.28.8)(postgres@3.4.7)(react@19.1.0)
|
||||||
|
multer:
|
||||||
|
specifier: ^1.4.5-lts.1
|
||||||
|
version: 1.4.5-lts.2
|
||||||
|
postgres:
|
||||||
|
specifier: ^3.4.5
|
||||||
|
version: 3.4.7
|
||||||
|
reflect-metadata:
|
||||||
|
specifier: ^0.2.2
|
||||||
|
version: 0.2.2
|
||||||
|
rxjs:
|
||||||
|
specifier: ^7.8.1
|
||||||
|
version: 7.8.2
|
||||||
|
uuid:
|
||||||
|
specifier: ^11.0.3
|
||||||
|
version: 11.0.3
|
||||||
|
devDependencies:
|
||||||
|
'@nestjs/cli':
|
||||||
|
specifier: ^10.4.9
|
||||||
|
version: 10.4.9(esbuild@0.27.0)
|
||||||
|
'@nestjs/schematics':
|
||||||
|
specifier: ^10.2.3
|
||||||
|
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
|
||||||
|
'@types/express':
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.5
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^1.4.12
|
||||||
|
version: 1.4.13
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.10.2
|
||||||
|
version: 22.19.1
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
|
'@typescript-eslint/eslint-plugin':
|
||||||
|
specifier: ^8.18.1
|
||||||
|
version: 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||||
|
'@typescript-eslint/parser':
|
||||||
|
specifier: ^8.18.1
|
||||||
|
version: 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||||
|
eslint:
|
||||||
|
specifier: ^9.17.0
|
||||||
|
version: 9.39.1(jiti@2.6.1)
|
||||||
|
eslint-config-prettier:
|
||||||
|
specifier: ^9.1.0
|
||||||
|
version: 9.1.2(eslint@9.39.1(jiti@2.6.1))
|
||||||
|
eslint-plugin-prettier:
|
||||||
|
specifier: ^5.2.1
|
||||||
|
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)
|
||||||
|
prettier:
|
||||||
|
specifier: ^3.4.2
|
||||||
|
version: 3.6.2
|
||||||
|
source-map-support:
|
||||||
|
specifier: ^0.5.21
|
||||||
|
version: 0.5.21
|
||||||
|
ts-loader:
|
||||||
|
specifier: ^9.5.1
|
||||||
|
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0))
|
||||||
|
ts-node:
|
||||||
|
specifier: ^10.9.2
|
||||||
|
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
|
||||||
|
tsconfig-paths:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.19.2
|
||||||
|
version: 4.20.6
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.2
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
|
apps/planta/apps/web:
|
||||||
|
dependencies:
|
||||||
|
'@manacore/shared-auth':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-auth
|
||||||
|
'@manacore/shared-auth-ui':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-auth-ui
|
||||||
|
'@manacore/shared-branding':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-branding
|
||||||
|
'@manacore/shared-i18n':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-i18n
|
||||||
|
'@manacore/shared-icons':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-icons
|
||||||
|
'@manacore/shared-tailwind':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-tailwind
|
||||||
|
'@manacore/shared-theme':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-theme
|
||||||
|
'@manacore/shared-theme-ui':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-theme-ui
|
||||||
|
'@manacore/shared-ui':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../../packages/shared-ui
|
||||||
|
'@planta/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared
|
||||||
|
svelte-i18n:
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.1(svelte@5.44.0)
|
||||||
|
devDependencies:
|
||||||
|
'@sveltejs/adapter-auto':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.3.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))
|
||||||
|
'@sveltejs/kit':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
|
||||||
|
'@sveltejs/vite-plugin-svelte':
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
|
||||||
|
'@tailwindcss/vite':
|
||||||
|
specifier: ^4.1.7
|
||||||
|
version: 4.1.17(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^20.0.0
|
||||||
|
version: 20.19.25
|
||||||
|
prettier:
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.6.2
|
||||||
|
prettier-plugin-svelte:
|
||||||
|
specifier: ^3.1.2
|
||||||
|
version: 3.4.0(prettier@3.6.2)(svelte@5.44.0)
|
||||||
|
svelte:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.44.0
|
||||||
|
svelte-check:
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3)
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^4.1.7
|
||||||
|
version: 4.1.17
|
||||||
|
tslib:
|
||||||
|
specifier: ^2.4.1
|
||||||
|
version: 2.8.1
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.9.3
|
||||||
|
vite:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
|
||||||
|
|
||||||
|
apps/planta/packages/shared:
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ~5.9.2
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
apps/todo:
|
apps/todo:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
typescript:
|
typescript:
|
||||||
|
|
@ -7072,6 +7272,10 @@ packages:
|
||||||
'@modelcontextprotocol/sdk':
|
'@modelcontextprotocol/sdk':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@google/generative-ai@0.21.0':
|
||||||
|
resolution: {integrity: sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@google/generative-ai@0.24.1':
|
'@google/generative-ai@0.24.1':
|
||||||
resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==}
|
resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
@ -9869,6 +10073,9 @@ packages:
|
||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
|
'@types/uuid@10.0.0':
|
||||||
|
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||||
|
|
||||||
'@types/validator@13.15.10':
|
'@types/validator@13.15.10':
|
||||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||||
|
|
||||||
|
|
@ -23440,6 +23647,8 @@ snapshots:
|
||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
'@google/generative-ai@0.21.0': {}
|
||||||
|
|
||||||
'@google/generative-ai@0.24.1': {}
|
'@google/generative-ai@0.24.1': {}
|
||||||
|
|
||||||
'@gorhom/bottom-sheet@5.2.7(@types/react@19.2.7)(react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.0(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
|
'@gorhom/bottom-sheet@5.2.7(@types/react@19.2.7)(react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.0(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
|
||||||
|
|
@ -27881,6 +28090,8 @@ snapshots:
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
|
'@types/uuid@10.0.0': {}
|
||||||
|
|
||||||
'@types/validator@13.15.10': {}
|
'@types/validator@13.15.10': {}
|
||||||
|
|
||||||
'@types/webxr@0.5.24': {}
|
'@types/webxr@0.5.24': {}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ ALL_DATABASES=(
|
||||||
"techbase"
|
"techbase"
|
||||||
"voxel_lava"
|
"voxel_lava"
|
||||||
"figgos"
|
"figgos"
|
||||||
|
"planta"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if specific service requested
|
# Check if specific service requested
|
||||||
|
|
@ -136,9 +137,13 @@ setup_service() {
|
||||||
create_db_if_not_exists "figgos"
|
create_db_if_not_exists "figgos"
|
||||||
push_schema "@figgos/backend" "figgos"
|
push_schema "@figgos/backend" "figgos"
|
||||||
;;
|
;;
|
||||||
|
planta)
|
||||||
|
create_db_if_not_exists "planta"
|
||||||
|
push_schema "@planta/backend" "planta"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${RED}Unknown service: $service${NC}"
|
echo -e "${RED}Unknown service: $service${NC}"
|
||||||
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos"
|
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -162,7 +167,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}"
|
||||||
echo "--------------------------------------"
|
echo "--------------------------------------"
|
||||||
|
|
||||||
# Push schemas for all known services
|
# Push schemas for all known services
|
||||||
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos; do
|
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos planta; do
|
||||||
setup_service "$service" 2>/dev/null || true
|
setup_service "$service" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue