Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0a814081d | ||
|
|
fb4c442542 | ||
|
|
122dad35ce | ||
|
|
9253cac1b2 | ||
|
|
7759bd0a05 | ||
|
|
27174bef18 | ||
|
|
0778f188bc | ||
|
|
aba22e2855 | ||
|
|
13f2dedf13 |
No files matched your search
+19
@@ -0,0 +1,19 @@
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
|
||||
# Environment Dateien (Geheimnisse!)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Build-Ausgaben
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
|
||||
# Betriebssystem
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
docker-data/
|
||||
Generated
+2741
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "onlypets-backend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "ts-node src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"build": "tsc",
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/core": "11.1.10",
|
||||
"@nestjs/platform-express": "^11.1.10",
|
||||
"@prisma/client": "^6.19.1",
|
||||
"express": "^4.17.1",
|
||||
"prisma": "^6.19.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"nodemon": "^3.1.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
migrations: {
|
||||
seed: 'ts-node ./prisma/seed.ts',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Pet" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"breed" TEXT NOT NULL,
|
||||
"birthDate" TIMESTAMP(3),
|
||||
"gender" TEXT,
|
||||
"bio" TEXT,
|
||||
"avatarUrl" TEXT,
|
||||
"visibility" TEXT NOT NULL DEFAULT 'public',
|
||||
"ownerId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Pet_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Post" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"petId" INTEGER NOT NULL,
|
||||
"authorId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Follow" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"followerId" INTEGER NOT NULL,
|
||||
"followingId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Follow_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Notification" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Follow_followerId_followingId_key" ON "Follow"("followerId", "followingId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Pet" ADD CONSTRAINT "Pet_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Post" ADD CONSTRAINT "Post_petId_fkey" FOREIGN KEY ("petId") REFERENCES "Pet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Follow" ADD CONSTRAINT "Follow_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Follow" ADD CONSTRAINT "Follow_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
migrations: {
|
||||
seed: 'ts-node ./prisma/seed.ts',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
email String @unique
|
||||
|
||||
pets Pet[]
|
||||
posts Post[]
|
||||
|
||||
following Follow[] @relation("Follower")
|
||||
followers Follow[] @relation("Following")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
notifications Notification[]
|
||||
}
|
||||
|
||||
|
||||
model Pet {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
type String // "Hund" | "Katze"
|
||||
breed String
|
||||
birthDate DateTime? // ⭐ Single Source of Truth für Alter
|
||||
gender String?
|
||||
bio String?
|
||||
avatarUrl String?
|
||||
visibility String @default("public") // public | followers
|
||||
|
||||
ownerId Int
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
posts Post[]
|
||||
}
|
||||
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
content String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
petId Int
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
|
||||
authorId Int
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
model Follow {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
followerId Int
|
||||
follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade)
|
||||
|
||||
followingId Int
|
||||
following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([followerId, followingId])
|
||||
}
|
||||
model Notification {
|
||||
id Int @id @default(autoincrement())
|
||||
type String // 'FOLLOW' | 'BIRTHDAY'
|
||||
payload Json
|
||||
read Boolean @default(false)
|
||||
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||
|
||||
/**
|
||||
* 1. USERS (ohne Passwort – Auth kommt später)
|
||||
*/
|
||||
const users = await Promise.all([
|
||||
prisma.user.upsert({
|
||||
where: { email: 'alice@onlypets.dev' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'alice@onlypets.dev',
|
||||
name: 'Alice',
|
||||
},
|
||||
}),
|
||||
prisma.user.upsert({
|
||||
where: { email: 'bob@onlypets.dev' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'bob@onlypets.dev',
|
||||
name: 'Bob',
|
||||
},
|
||||
}),
|
||||
prisma.user.upsert({
|
||||
where: { email: 'carla@onlypets.dev' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'carla@onlypets.dev',
|
||||
name: 'Carla',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
/**
|
||||
* 2. PETS
|
||||
*/
|
||||
const pets = await Promise.all([
|
||||
prisma.pet.create({
|
||||
data: {
|
||||
name: 'Milo',
|
||||
type: 'Katze',
|
||||
breed: 'EKH',
|
||||
birthDate: new Date('2020-05-12'),
|
||||
ownerId: users[0].id,
|
||||
visibility: 'public',
|
||||
},
|
||||
}),
|
||||
prisma.pet.create({
|
||||
data: {
|
||||
name: 'Luna',
|
||||
type: 'Hund',
|
||||
breed: 'Labrador',
|
||||
birthDate: new Date('2019-11-03'),
|
||||
ownerId: users[1].id,
|
||||
visibility: 'followers',
|
||||
},
|
||||
}),
|
||||
prisma.pet.create({
|
||||
data: {
|
||||
name: 'Oscar',
|
||||
type: 'Katze',
|
||||
breed: 'BKH',
|
||||
birthDate: new Date('2022-02-01'),
|
||||
ownerId: users[2].id,
|
||||
visibility: 'public',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
/**
|
||||
* 3. POSTS
|
||||
*/
|
||||
await Promise.all([
|
||||
prisma.post.create({
|
||||
data: {
|
||||
content: 'Milo liebt die Sonne ☀️',
|
||||
authorId: users[0].id,
|
||||
petId: pets[0].id,
|
||||
},
|
||||
}),
|
||||
prisma.post.create({
|
||||
data: {
|
||||
content: 'Luna beim Spaziergang 🐕',
|
||||
authorId: users[1].id,
|
||||
petId: pets[1].id,
|
||||
},
|
||||
}),
|
||||
prisma.post.create({
|
||||
data: {
|
||||
content: 'Oscar schläft schon wieder 😴',
|
||||
authorId: users[2].id,
|
||||
petId: pets[2].id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* 4. FOLLOWS
|
||||
*/
|
||||
await Promise.all([
|
||||
prisma.follow.create({
|
||||
data: {
|
||||
followerId: users[0].id,
|
||||
followingId: users[1].id,
|
||||
},
|
||||
}),
|
||||
prisma.follow.create({
|
||||
data: {
|
||||
followerId: users[0].id,
|
||||
followingId: users[2].id,
|
||||
},
|
||||
}),
|
||||
prisma.follow.create({
|
||||
data: {
|
||||
followerId: users[1].id,
|
||||
followingId: users[2].id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('✅ Seed erfolgreich ausgeführt');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seed fehlgeschlagen:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { DummyAuthGuard } from './auth/guards/dummy-auth.guard';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: DummyAuthGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Auth (Backend)
|
||||
|
||||
Dieses Verzeichnis enthält das **Auth-Fundament** für OnlyPets.
|
||||
|
||||
## Architekturprinzipien
|
||||
|
||||
- Guard-first-Ansatz
|
||||
- Keine Auth-Logik in Services
|
||||
- Controller entscheiden nur über @Public()
|
||||
- `req.user` ist immer definiert (FakeUser oder null)
|
||||
|
||||
## Aktueller Status
|
||||
|
||||
- DummyAuthGuard ist global aktiv
|
||||
- Fake-User für DEV via ENV steuerbar
|
||||
- Rollen vorbereitet: USER / ADMIN / MOD
|
||||
- Keine echte Auth (JWT/Password) implementiert
|
||||
|
||||
## ENV (DEV)
|
||||
|
||||
```env
|
||||
AUTH_MODE=dummy
|
||||
DEV_FAKE_USER_ID=1
|
||||
DEV_FAKE_USER_ROLE=ADMIN
|
||||
DEV_FAKE_USER=true
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { RequestUser, UserRole } from '../types/request-user.type';
|
||||
|
||||
@Injectable()
|
||||
export class DummyAuthGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
||||
IS_PUBLIC_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
if (isPublic) {
|
||||
request.user = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.env.AUTH_MODE !== 'dummy') {
|
||||
request.user = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const rawRole = process.env.DEV_FAKE_USER_ROLE;
|
||||
const role: UserRole =
|
||||
rawRole === 'ADMIN' || rawRole === 'MOD' || rawRole === 'USER'
|
||||
? rawRole
|
||||
: 'USER';
|
||||
|
||||
const fakeUser: RequestUser = {
|
||||
id: Number(process.env.DEV_FAKE_USER_ID ?? 0),
|
||||
role,
|
||||
isFake: process.env.DEV_FAKE_USER === 'true',
|
||||
};
|
||||
|
||||
request.user = fakeUser;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type UserRole = 'USER' | 'ADMIN' | 'MOD';
|
||||
|
||||
export interface RequestUser {
|
||||
/** User-ID aus DB oder Fake-User */
|
||||
id: number;
|
||||
|
||||
/** Rollenbasis für Guards & Admin-Features */
|
||||
role: UserRole;
|
||||
|
||||
/** Für DEV / Dummy-Auth */
|
||||
isFake?: boolean;
|
||||
|
||||
/** Optional, future-proof (z. B. Feed-Optimierung) */
|
||||
petIds?: number[];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Req,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('follows')
|
||||
export class FollowsController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Post(':userId')
|
||||
async follow(@Req() req, @Param('userId') userId: string) {
|
||||
const followerId = req.user.id;
|
||||
const followingId = Number(userId);
|
||||
|
||||
// ❌ Selbst-Follow verhindern
|
||||
if (followerId === followingId) {
|
||||
throw new BadRequestException({
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Du kannst dir nicht selbst folgen.',
|
||||
field: 'userId',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 1️⃣ Follow erstellen
|
||||
const follow = await this.prisma.follow.create({
|
||||
data: {
|
||||
followerId,
|
||||
followingId,
|
||||
},
|
||||
});
|
||||
|
||||
// 2️⃣ Notification für den Gefolgten erzeugen
|
||||
await this.prisma.notification.create({
|
||||
data: {
|
||||
type: 'FOLLOW',
|
||||
userId: followingId,
|
||||
payload: {
|
||||
followerId,
|
||||
followerName: req.user.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return follow;
|
||||
} catch (err) {
|
||||
// ❌ Bereits gefolgt (Unique-Constraint)
|
||||
throw new BadRequestException({
|
||||
error: 'ALREADY_FOLLOWING',
|
||||
message: 'Du folgst diesem Nutzer bereits.',
|
||||
field: 'userId',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':userId')
|
||||
async unfollow(@Req() req, @Param('userId') userId: string) {
|
||||
return this.prisma.follow.delete({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: req.user.id,
|
||||
followingId: Number(userId),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from './auth/decorators/public.decorator';
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Public()
|
||||
@Get('/health')
|
||||
health() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`🚀 Backend running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, Req } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
getMyNotifications(@Req() req) {
|
||||
return this.prisma.notification.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export class CreatePetDto {
|
||||
name: string;
|
||||
type: 'Hund' | 'Katze';
|
||||
breed: string;
|
||||
|
||||
// ENTWEDER:
|
||||
birthDate?: string; // ISO
|
||||
|
||||
// ODER:
|
||||
ageYears?: number; // nur beim Erstellen erlaubt
|
||||
|
||||
gender?: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
visibility?: 'public' | 'followers';
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class UpdatePetDto {
|
||||
birthDate?: string; // ISO – einzige Altersquelle
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
visibility?: 'public' | 'followers';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Patch, Param, Body, Req } from '@nestjs/common';
|
||||
|
||||
@Patch(':id')
|
||||
updatePet(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePetDto,
|
||||
@Req() req,
|
||||
) {
|
||||
return this.petsService.updatePet(
|
||||
Number(id),
|
||||
req.user.id,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePetDto } from './dto/create-pet.dto';
|
||||
import { UpdatePetDto } from './dto/update-pet.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PetsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// -------------------------------
|
||||
// CREATE PET
|
||||
// -------------------------------
|
||||
async createPet(userId: number, dto: CreatePetDto) {
|
||||
// ❌ Beides gleichzeitig verboten
|
||||
if (dto.birthDate && dto.ageYears !== undefined) {
|
||||
throw new BadRequestException({
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Entweder Geburtsdatum ODER Alter angeben, nicht beides.',
|
||||
field: 'birthDate',
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ Nichts angegeben verboten
|
||||
if (!dto.birthDate && dto.ageYears === undefined) {
|
||||
throw new BadRequestException({
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Geburtsdatum oder Alter muss angegeben werden.',
|
||||
field: 'birthDate',
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ BirthDate ableiten (Single Source of Truth)
|
||||
let birthDate: Date | null = null;
|
||||
|
||||
if (dto.birthDate) {
|
||||
birthDate = new Date(dto.birthDate);
|
||||
}
|
||||
|
||||
if (dto.ageYears !== undefined) {
|
||||
const today = new Date();
|
||||
birthDate = new Date(
|
||||
today.getFullYear() - dto.ageYears,
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
}
|
||||
|
||||
// ❌ Zukunftsdatum verhindern
|
||||
if (birthDate && birthDate > new Date()) {
|
||||
throw new BadRequestException({
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Geburtsdatum darf nicht in der Zukunft liegen.',
|
||||
field: 'birthDate',
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.pet.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
breed: dto.breed,
|
||||
birthDate, // ⭐ WICHTIG: berechnetes Datum
|
||||
gender: dto.gender,
|
||||
bio: dto.bio,
|
||||
avatarUrl: dto.avatarUrl,
|
||||
visibility: dto.visibility ?? 'public',
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// UPDATE PET
|
||||
// -------------------------------
|
||||
async updatePet(
|
||||
petId: number,
|
||||
userId: number,
|
||||
dto: UpdatePetDto,
|
||||
) {
|
||||
const pet = await this.prisma.pet.findUnique({
|
||||
where: { id: petId },
|
||||
});
|
||||
|
||||
if (!pet || pet.ownerId !== userId) {
|
||||
throw new NotFoundException({
|
||||
error: 'NOT_FOUND',
|
||||
message: 'Pet nicht gefunden.',
|
||||
});
|
||||
}
|
||||
|
||||
let birthDate = pet.birthDate;
|
||||
|
||||
if (dto.birthDate) {
|
||||
birthDate = new Date(dto.birthDate);
|
||||
|
||||
if (birthDate > new Date()) {
|
||||
throw new BadRequestException({
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Geburtsdatum darf nicht in der Zukunft liegen.',
|
||||
field: 'birthDate',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.pet.update({
|
||||
where: { id: petId },
|
||||
data: {
|
||||
birthDate,
|
||||
bio: dto.bio,
|
||||
avatarUrl: dto.avatarUrl,
|
||||
visibility: dto.visibility,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||
import { PostsService } from './posts.service';
|
||||
|
||||
@Controller('posts')
|
||||
export class PostsController {
|
||||
constructor(private readonly postsService: PostsService) {}
|
||||
|
||||
@Get()
|
||||
getFeed(
|
||||
@Req() req,
|
||||
@Query('take') take?: string,
|
||||
@Query('cursorId') cursorId?: string,
|
||||
) {
|
||||
return this.postsService.getFeed(req.user.id, {
|
||||
mode: 'mixed',
|
||||
take: take ? Number(take) : undefined,
|
||||
cursorId: cursorId ? Number(cursorId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('discovery')
|
||||
getDiscovery(
|
||||
@Req() req,
|
||||
@Query('take') take?: string,
|
||||
@Query('cursorId') cursorId?: string,
|
||||
) {
|
||||
return this.postsService.getFeed(req.user.id, {
|
||||
mode: 'discovery',
|
||||
take: take ? Number(take) : undefined,
|
||||
cursorId: cursorId ? Number(cursorId) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type FeedMode = 'mixed' | 'discovery';
|
||||
|
||||
type FeedOptions = {
|
||||
mode?: FeedMode;
|
||||
take?: number;
|
||||
cursorId?: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PostsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getFeed(userId: number, options?: FeedOptions) {
|
||||
const mode: FeedMode = options?.mode ?? 'mixed';
|
||||
const take = Math.min(Math.max(options?.take ?? 20, 1), 50);
|
||||
const cursorId = options?.cursorId;
|
||||
|
||||
const include = {
|
||||
pet: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
birthDate: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
select: {
|
||||
name: true,
|
||||
_count: {
|
||||
select: {
|
||||
followers: true,
|
||||
following: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
} as const;
|
||||
|
||||
// Sichtbarkeit
|
||||
const baseWhere =
|
||||
mode === 'discovery'
|
||||
? {
|
||||
pet: { visibility: 'public' },
|
||||
}
|
||||
: {
|
||||
pet: {
|
||||
OR: [
|
||||
{ visibility: 'public' },
|
||||
{
|
||||
visibility: 'followers',
|
||||
owner: {
|
||||
followers: {
|
||||
some: {
|
||||
followerId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const commonQuery = {
|
||||
orderBy: [
|
||||
{ createdAt: 'desc' as const },
|
||||
{ id: 'desc' as const },
|
||||
],
|
||||
include,
|
||||
...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}),
|
||||
};
|
||||
|
||||
// -------- Discovery only --------
|
||||
if (mode === 'discovery') {
|
||||
const items = await this.prisma.post.findMany({
|
||||
where: baseWhere,
|
||||
take,
|
||||
...commonQuery,
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursorId:
|
||||
items.length === take ? items[items.length - 1].id : null,
|
||||
meta: { mode, take },
|
||||
};
|
||||
}
|
||||
|
||||
// -------- Mixed Feed (85 / 15) --------
|
||||
const discoveryCount = Math.max(1, Math.round(take * 0.15));
|
||||
const normalCount = take - discoveryCount;
|
||||
|
||||
const [normalItems, discoveryItems] = await Promise.all([
|
||||
this.prisma.post.findMany({
|
||||
where: baseWhere,
|
||||
take: normalCount,
|
||||
...commonQuery,
|
||||
}),
|
||||
this.prisma.post.findMany({
|
||||
where: { pet: { visibility: 'public' } },
|
||||
take: discoveryCount,
|
||||
...commonQuery,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Ruhige Mischung + Duplikat-Schutz
|
||||
const seen = new Set<number>();
|
||||
const merged = [];
|
||||
|
||||
const n = [...normalItems];
|
||||
const d = [...discoveryItems];
|
||||
|
||||
while (merged.length < take && (n.length || d.length)) {
|
||||
for (let i = 0; i < 6 && n.length && merged.length < take; i++) {
|
||||
const item = n.shift();
|
||||
if (item && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (d.length && merged.length < take) {
|
||||
const item = d.shift();
|
||||
if (item && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (merged.length < take && n.length) {
|
||||
const item = n.shift();
|
||||
if (item && !seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: merged,
|
||||
nextCursorId:
|
||||
merged.length === take ? merged[merged.length - 1].id : null,
|
||||
meta: {
|
||||
mode,
|
||||
take,
|
||||
normalCount,
|
||||
discoveryCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import { RequestUser } from '../auth/types/request-user.type';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: RequestUser | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,23 @@
|
||||
async getUserProfile(userId: number) {
|
||||
const [user, followersCount, followingCount] = await Promise.all([
|
||||
this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.follow.count({
|
||||
where: { followingId: userId },
|
||||
}),
|
||||
this.prisma.follow.count({
|
||||
where: { followerId: userId },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...user,
|
||||
followersCount,
|
||||
followingCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"target": "ES2021",
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: onlypets-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "55432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: onlypets_user
|
||||
POSTGRES_PASSWORD: onlypets_pass
|
||||
POSTGRES_DB: onlypets
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
|
||||
|
||||
export type PostCardData = {
|
||||
id: number;
|
||||
@@ -31,6 +32,7 @@ export default function PostCard({
|
||||
}: PostCardProps) {
|
||||
const likeRef = useRef<HTMLButtonElement | null>(null);
|
||||
const commentRef = useRef<HTMLButtonElement | null>(null);
|
||||
const isBirthday = post.petBirthDate && isBirthdayToday(post.petBirthDate);
|
||||
|
||||
function animate(
|
||||
ref: React.RefObject<HTMLButtonElement>,
|
||||
@@ -50,11 +52,13 @@ export default function PostCard({
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`postcard ${
|
||||
isDiscovery ? 'postcard--discovery' : ''
|
||||
}`}
|
||||
className={`postcard
|
||||
${isDiscovery ? 'postcard--discovery' : ''}
|
||||
${isBirthday ? 'postcard--birthday' : ''}
|
||||
`}
|
||||
>
|
||||
|
||||
|
||||
{/* Klickbarer Bereich → Preview */}
|
||||
<div
|
||||
className="postcard-main"
|
||||
@@ -70,14 +74,31 @@ export default function PostCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="postcard-title-row">
|
||||
<strong className="postcard-title">
|
||||
{post.petName}
|
||||
</strong>
|
||||
<span className="postcard-meta">
|
||||
· {post.petType} · {post.authorName} · {post.createdAt}
|
||||
<div className="postcard-title-row">
|
||||
<strong className="postcard-title">
|
||||
{post.petName}
|
||||
</strong>
|
||||
|
||||
<span className="postcard-meta">
|
||||
· {post.petType}
|
||||
{post.petBirthDate && ` · ${formatPetAge(post.petBirthDate)}`}
|
||||
· {post.authorName}
|
||||
· {post.createdAt}
|
||||
</span>
|
||||
|
||||
{isBirthday && (
|
||||
<span
|
||||
className="birthday-dot"
|
||||
title="Heute ist Geburtstag 🎉"
|
||||
aria-label="Geburtstag"
|
||||
>
|
||||
🎂
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<p className="postcard-text">{post.text}</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
|
||||
import PostCard, {
|
||||
PostCardData,
|
||||
} from '@/app/components/PostCard/PostCard';
|
||||
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
|
||||
|
||||
/* =======================
|
||||
MOCK DATA
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
|
||||
import PostCard, {
|
||||
PostCardData,
|
||||
} from '@/app/components/PostCard/PostCard';
|
||||
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
|
||||
|
||||
/* =======================
|
||||
MOCK: USER KONTEXT
|
||||
|
||||
@@ -180,11 +180,11 @@ body {
|
||||
padding: var(--space-lg) var(--space-md) var(--space-md);
|
||||
}
|
||||
|
||||
.feed-header p,
|
||||
/*.feed-header p,
|
||||
.explore-header p {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
}*/
|
||||
.post-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -721,3 +721,30 @@ select:disabled {
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.postcard-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.birthday-dot {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-primary-soft);
|
||||
border: 1px solid rgba(46, 125, 50, 0.25);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Birthday Highlight */
|
||||
.postcard--birthday {
|
||||
border-color: rgba(46, 125, 50, 0.35);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(46, 125, 50, 0.18),
|
||||
var(--shadow-sm);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export function calculatePetAge(birthDate: string) {
|
||||
const today = new Date();
|
||||
const birth = new Date(birthDate);
|
||||
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const hasHadBirthdayThisYear =
|
||||
today.getMonth() > birth.getMonth() ||
|
||||
(today.getMonth() === birth.getMonth() &&
|
||||
today.getDate() >= birth.getDate());
|
||||
|
||||
if (!hasHadBirthdayThisYear) {
|
||||
age -= 1;
|
||||
}
|
||||
|
||||
return age < 0 ? 0 : age;
|
||||
}
|
||||
|
||||
export function isBirthdayToday(birthDate: string) {
|
||||
const today = new Date();
|
||||
const birth = new Date(birthDate);
|
||||
|
||||
return (
|
||||
today.getDate() === birth.getDate() &&
|
||||
today.getMonth() === birth.getMonth()
|
||||
);
|
||||
}
|
||||
|
||||
export function formatPetAge(birthDate: string) {
|
||||
const age = calculatePetAge(birthDate);
|
||||
|
||||
if (age === 0) return 'unter 1 Jahr';
|
||||
if (age === 1) return '1 Jahr';
|
||||
return `${age} Jahre`;
|
||||
}
|
||||
@@ -1,326 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { dogBreeds, catBreeds } from '@/app/data/breeds';
|
||||
|
||||
type PetType = 'Hund' | 'Katze';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
formatPetAge,
|
||||
} from '@/app/lib/petAge';
|
||||
|
||||
/* Mock – später API */
|
||||
type Pet = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: PetType;
|
||||
type: 'Hund' | 'Katze';
|
||||
breed: string;
|
||||
birthDate?: string;
|
||||
gender?: 'Männlich' | 'Weiblich' | 'Unbekannt';
|
||||
bio?: string;
|
||||
visibility: 'public' | 'followers';
|
||||
};
|
||||
|
||||
// 🔧 Mock-Daten (später Backend)
|
||||
/* MOCK-DATEN */
|
||||
const mockPets: Pet[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Luna',
|
||||
type: 'Katze',
|
||||
breed: 'Bengal x Mix',
|
||||
bio: 'Neugierig, verspielt und liebt Sonne.',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Bello',
|
||||
type: 'Hund',
|
||||
breed: 'Labrador Retriever x Mix',
|
||||
bio: 'Immer gute Laune.',
|
||||
breed: 'Bengal',
|
||||
birthDate: '2021-05-14',
|
||||
gender: 'Weiblich',
|
||||
bio: 'Neugierig und verspielt',
|
||||
visibility: 'public',
|
||||
},
|
||||
];
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function EditPetPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const petId = Number(id);
|
||||
|
||||
export default function EditPetPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const petId = Number(params.id);
|
||||
const pet = mockPets.find((p) => p.id === petId);
|
||||
|
||||
if (!pet) {
|
||||
return <p>Haustier nicht gefunden.</p>;
|
||||
}
|
||||
|
||||
return <EditPetForm pet={pet} />;
|
||||
}
|
||||
|
||||
/* ---------- FORMULAR (Client Component) ---------- */
|
||||
|
||||
function EditPetForm({ pet }: { pet: Pet }) {
|
||||
const [name, setName] = useState(pet.name);
|
||||
const [type, setType] = useState<PetType>(pet.type);
|
||||
const [breed, setBreed] = useState(pet.breed);
|
||||
const [breedSearch, setBreedSearch] = useState('');
|
||||
const [bio, setBio] = useState(pet.bio ?? '');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
|
||||
const breeds = type === 'Hund' ? dogBreeds : catBreeds;
|
||||
const filteredBreeds = breeds.filter((b) =>
|
||||
b.toLowerCase().includes(breedSearch.toLowerCase())
|
||||
const [name, setName] = useState('');
|
||||
const [birthDate, setBirthDate] = useState('');
|
||||
const [ageFallback, setAgeFallback] = useState('');
|
||||
const [gender, setGender] = useState<'Männlich' | 'Weiblich' | 'Unbekannt'>(
|
||||
'Unbekannt'
|
||||
);
|
||||
const [bio, setBio] = useState('');
|
||||
const [visibility, setVisibility] = useState<'public' | 'followers'>(
|
||||
'public'
|
||||
);
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
/* Initialwerte setzen */
|
||||
useEffect(() => {
|
||||
if (!pet) return;
|
||||
|
||||
setName(pet.name);
|
||||
setBirthDate(pet.birthDate || '');
|
||||
setGender(pet.gender || 'Unbekannt');
|
||||
setBio(pet.bio || '');
|
||||
setVisibility(pet.visibility);
|
||||
}, [pet]);
|
||||
|
||||
if (!pet) {
|
||||
return (
|
||||
<section className="empty-state">
|
||||
<strong>Haustier nicht gefunden</strong>
|
||||
<span>Bitte gehe zurück zur Übersicht.</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Name darf nicht leer sein.');
|
||||
setError('Der Name darf nicht leer sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!breed) {
|
||||
setError('Bitte wähle eine Rasse aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPetPayload = {
|
||||
id: pet.id,
|
||||
const updatedPet = {
|
||||
...pet,
|
||||
name,
|
||||
type,
|
||||
breed,
|
||||
bio: bio || null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
birthDate: birthDate || null,
|
||||
ageFallback: birthDate ? null : ageFallback || null,
|
||||
gender,
|
||||
bio,
|
||||
visibility,
|
||||
};
|
||||
|
||||
console.log('Haustier aktualisiert (Mock):', updatedPetPayload);
|
||||
console.log('UPDATED PET:', updatedPet);
|
||||
|
||||
setSuccess('Änderungen gespeichert ✅');
|
||||
setSuccess('Änderungen wurden gespeichert.');
|
||||
setTimeout(() => {
|
||||
router.push(`/pets/${pet.id}`);
|
||||
}, 800);
|
||||
}
|
||||
function handleDelete() {
|
||||
console.log('Haustier gelöscht (Mock):', pet.id);
|
||||
|
||||
// Später:
|
||||
// await fetch(`/api/pets/${pet.id}`, { method: 'DELETE' })
|
||||
// router.push('/pets');
|
||||
|
||||
setShowDeleteConfirm(false);
|
||||
alert('Haustier wurde technisch gelöscht (Mock)');
|
||||
}
|
||||
function handleCancel() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<section className="pets-page">
|
||||
<header className="pets-header">
|
||||
<h1>Haustier bearbeiten</h1>
|
||||
<p style={{ color: '#555' }}>
|
||||
Passe die Informationen deines Haustiers an.
|
||||
</p>
|
||||
<p>Du kannst die Angaben jederzeit korrigieren.</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
border: '1px solid #e5e5e5',
|
||||
borderRadius: '16px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
>
|
||||
<form className="pet-form" onSubmit={handleSubmit}>
|
||||
{/* Name */}
|
||||
<div style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<strong>Name</strong>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{
|
||||
padding: '0.7rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Name <span className="required">*</span>
|
||||
</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* Art */}
|
||||
<div style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<strong>Tier-Art</strong>
|
||||
<select
|
||||
value={type}
|
||||
{/* Art & Rasse (read-only) */}
|
||||
<div className="form-group">
|
||||
<label>Tier-Art</label>
|
||||
<input value={pet.type} disabled />
|
||||
<small>Kann nachträglich nicht geändert werden.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Rasse</label>
|
||||
<input value={pet.breed} disabled />
|
||||
<small>Kann nachträglich nicht geändert werden.</small>
|
||||
</div>
|
||||
|
||||
{/* Geburtsdatum */}
|
||||
<div className="form-group">
|
||||
<label>Geburtsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={birthDate}
|
||||
onChange={(e) => {
|
||||
setType(e.target.value as PetType);
|
||||
setBreed('');
|
||||
setBreedSearch('');
|
||||
}}
|
||||
style={{
|
||||
padding: '0.6rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
maxWidth: '220px',
|
||||
}}
|
||||
>
|
||||
<option value="Katze">Katze</option>
|
||||
<option value="Hund">Hund</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rasse */}
|
||||
<div style={{ display: 'grid', gap: '0.5rem' }}>
|
||||
<strong>Rasse</strong>
|
||||
|
||||
<input
|
||||
placeholder="Rasse suchen …"
|
||||
value={breedSearch}
|
||||
onChange={(e) => setBreedSearch(e.target.value)}
|
||||
style={{
|
||||
padding: '0.6rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
setBirthDate(e.target.value);
|
||||
if (e.target.value) setAgeFallback('');
|
||||
}}
|
||||
/>
|
||||
{birthDate && (
|
||||
<small>
|
||||
Aktuelles Alter: <strong>{formatPetAge(birthDate)}</strong>
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fallback-Alter */}
|
||||
{!birthDate && (
|
||||
<div className="form-group">
|
||||
<label>Alter (geschätzt)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={ageFallback}
|
||||
onChange={(e) => setAgeFallback(e.target.value)}
|
||||
/>
|
||||
<small>Wird verwendet, wenn kein Geburtsdatum bekannt ist.</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geschlecht */}
|
||||
<div className="form-group">
|
||||
<label>Geschlecht</label>
|
||||
<select
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
size={Math.min(filteredBreeds.length, 6)}
|
||||
style={{
|
||||
padding: '0.6rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
value={gender}
|
||||
onChange={(e) =>
|
||||
setGender(e.target.value as 'Männlich' | 'Weiblich' | 'Unbekannt')
|
||||
}
|
||||
>
|
||||
{filteredBreeds.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
<option value="Unbekannt">Unbekannt</option>
|
||||
<option value="Männlich">Männlich</option>
|
||||
<option value="Weiblich">Weiblich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<strong>Kurzbeschreibung</strong>
|
||||
<div className="form-group">
|
||||
<label>Kurzbeschreibung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
rows={3}
|
||||
style={{
|
||||
padding: '0.8rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sichtbarkeit */}
|
||||
<div className="form-group">
|
||||
<label>Sichtbarkeit</label>
|
||||
<select
|
||||
value={visibility}
|
||||
onChange={(e) =>
|
||||
setVisibility(e.target.value as 'public' | 'followers')
|
||||
}
|
||||
>
|
||||
<option value="public">Öffentlich</option>
|
||||
<option value="followers">Nur für Abonnenten</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Meldungen */}
|
||||
{error && <div style={{ background: '#f8d7da', padding: '0.7rem' }}>{error}</div>}
|
||||
{success && <div style={{ background: '#d1e7dd', padding: '0.7rem' }}>{success}</div>}
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
{success && <div className="form-success">{success}</div>}
|
||||
|
||||
{/* Aktionen */}
|
||||
<div style={{ display: 'flex', gap: '0.8rem' }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '0.8rem 1.2rem',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
backgroundColor: '#2E7D32',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="button-primary">
|
||||
Änderungen speichern
|
||||
</button>
|
||||
|
||||
<Link href={`/pets/${pet.id}`}>Abbrechen</Link>
|
||||
<hr style={{ margin: '1rem 0', borderColor: '#eee' }} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
style={{
|
||||
padding: '0.7rem 1.2rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #d32f2f',
|
||||
backgroundColor: 'white',
|
||||
color: '#d32f2f',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Haustier löschen
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="button-danger"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{showDeleteConfirm && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '1.2rem',
|
||||
maxWidth: '420px',
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<h3>Haustier wirklich löschen?</h3>
|
||||
|
||||
<p style={{ color: '#555' }}>
|
||||
Alle Beiträge dieses Haustiers gehen dabei verloren.
|
||||
Diese Aktion kann nicht rückgängig gemacht werden. Bist du dir sicher?
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '0.6rem 1rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
backgroundColor: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
style={{
|
||||
padding: '0.6rem 1rem',
|
||||
borderRadius: '10px',
|
||||
border: 'none',
|
||||
backgroundColor: '#d32f2f',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import PostCard from '@/app/components/PostCard/PostCard';
|
||||
import {calculatePetAge, formatPetAge, isBirthdayToday,} from '@/app/lib/petAge';
|
||||
|
||||
|
||||
|
||||
type Pet = {
|
||||
id: number;
|
||||
@@ -32,6 +35,7 @@ const mockPets: Pet[] = [
|
||||
breed: 'Bengal',
|
||||
age: '2 Jahre',
|
||||
owner: 'Anna',
|
||||
birthDate: '2021-06-15',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -40,6 +44,7 @@ const mockPets: Pet[] = [
|
||||
breed: 'Labrador',
|
||||
age: '4 Jahre',
|
||||
owner: 'Markus',
|
||||
birthDate: '2020-03-22',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -54,6 +59,7 @@ const mockPosts: Post[] = [
|
||||
createdAt: 'vor 3 Stunden',
|
||||
likes: 12,
|
||||
comments: 3,
|
||||
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
@@ -65,6 +71,7 @@ const mockPosts: Post[] = [
|
||||
createdAt: 'gestern',
|
||||
likes: 7,
|
||||
comments: 1,
|
||||
|
||||
},
|
||||
];
|
||||
|
||||
@@ -94,9 +101,10 @@ export default function PetDetailPage() {
|
||||
<h1>{pet.name}</h1>
|
||||
|
||||
<span className="pet-detail-meta">
|
||||
{pet.type} · {pet.breed}
|
||||
{pet.age && ` · ${pet.age}`}
|
||||
</span>
|
||||
{pet.type} · {pet.breed}
|
||||
{pet.birthDate && ` · ${formatPetAge(pet.birthDate)}`}
|
||||
</span>
|
||||
|
||||
|
||||
<span className="pet-detail-owner">
|
||||
Betreut von {pet.owner}
|
||||
|
||||
+130
-135
@@ -3,43 +3,42 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { dogBreeds, catBreeds } from '@/app/data/breeds';
|
||||
|
||||
type PetType = 'Hund' | 'Katze' | '';
|
||||
|
||||
export default function NewPetPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Pflichtfelder
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<PetType>('');
|
||||
const [breed, setBreed] = useState('');
|
||||
const [breedSearch, setBreedSearch] = useState('');
|
||||
|
||||
// Erweiterte Felder
|
||||
const [birthDate, setBirthDate] = useState('');
|
||||
const [ageFallback, setAgeFallback] = useState('');
|
||||
const [gender, setGender] = useState<'Männlich' | 'Weiblich' | 'Unbekannt'>(
|
||||
'Unbekannt'
|
||||
);
|
||||
const [bio, setBio] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
function handleCancel() {
|
||||
router.back(); // geht zur vorherigen Seite zurück
|
||||
}
|
||||
|
||||
const breeds =
|
||||
type === 'Hund'
|
||||
? dogBreeds
|
||||
: type === 'Katze'
|
||||
? catBreeds
|
||||
: [];
|
||||
|
||||
const filteredBreeds = breeds.filter((b) =>
|
||||
b.toLowerCase().includes(breedSearch.toLowerCase())
|
||||
const [visibility, setVisibility] = useState<'public' | 'followers'>(
|
||||
'public'
|
||||
);
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
// UX States
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const breeds = type === 'Hund' ? dogBreeds : catBreeds;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Bitte gib einen Namen für dein Haustier ein.');
|
||||
setError('Bitte gib einen Namen für dein Tier an.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,120 +48,80 @@ function handleCancel() {
|
||||
}
|
||||
|
||||
if (!breed) {
|
||||
setError('Bitte wähle eine Rasse aus.');
|
||||
setError('Bitte wähle eine Rasse oder „Mix“ aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newPetPayload = {
|
||||
const petPayload = {
|
||||
name,
|
||||
type,
|
||||
breed,
|
||||
bio: bio || null,
|
||||
createdAt: new Date().toISOString(),
|
||||
birthDate: birthDate || null,
|
||||
ageFallback: birthDate ? null : ageFallback || null,
|
||||
gender,
|
||||
bio,
|
||||
visibility,
|
||||
};
|
||||
|
||||
console.log('Neues Haustier (Mock):', newPetPayload);
|
||||
console.log('NEW PET:', petPayload);
|
||||
|
||||
setSuccess('Haustier wurde technisch angelegt ✅');
|
||||
setName('');
|
||||
setType('');
|
||||
setBreed('');
|
||||
setBreedSearch('');
|
||||
setBio('');
|
||||
setSuccess('Haustier wurde erfolgreich angelegt.');
|
||||
setTimeout(() => {
|
||||
router.push('/pets');
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<h1 style={{ marginBottom: '0.5rem' }}>Haustier hinzufügen</h1>
|
||||
<p style={{ margin: 0, color: '#555' }}>
|
||||
Lege ein neues Haustier für dein Profil an.
|
||||
</p>
|
||||
<section className="pets-page">
|
||||
<header className="pets-header">
|
||||
<h1>Haustier hinzufügen</h1>
|
||||
<p>Lege ein neues Tier für dein Profil an.</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
border: '1px solid #e5e5e5',
|
||||
borderRadius: '16px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
>
|
||||
<form className="pet-form" onSubmit={handleSubmit}>
|
||||
{/* Name */}
|
||||
<div style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<strong>Name</strong>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Name <span className="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z. B. Luna"
|
||||
style={{
|
||||
padding: '0.7rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Art */}
|
||||
<div style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<strong>Tier-Art</strong>
|
||||
{/* Tier-Art */}
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Tier-Art <span className="required">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => {
|
||||
setType(e.target.value as PetType);
|
||||
setBreed('');
|
||||
setBreedSearch('');
|
||||
}}
|
||||
style={{
|
||||
padding: '0.6rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
maxWidth: '220px',
|
||||
}}
|
||||
>
|
||||
<option value="">Bitte auswählen</option>
|
||||
<option value="Katze">Katze</option>
|
||||
<option value="Hund">Hund</option>
|
||||
<option value="Katze">Katze</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rasse */}
|
||||
{type && (
|
||||
<div style={{ display: 'grid', gap: '0.5rem' }}>
|
||||
<strong>Rasse</strong>
|
||||
|
||||
<input
|
||||
placeholder="Rasse suchen …"
|
||||
value={breedSearch}
|
||||
onChange={(e) => setBreedSearch(e.target.value)}
|
||||
style={{
|
||||
padding: '0.6rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
size={Math.min(filteredBreeds.length, 6)}
|
||||
style={{
|
||||
padding: '0.6rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
>
|
||||
{filteredBreeds.map((b) => (
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Rasse <span className="required">*</span>
|
||||
</label>
|
||||
<select value={breed} onChange={(e) => setBreed(e.target.value)}>
|
||||
<option value="">Bitte auswählen</option>
|
||||
{breeds.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
@@ -171,58 +130,94 @@ function handleCancel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geburtsdatum */}
|
||||
<div className="form-group">
|
||||
<label>Geburtsdatum (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={birthDate}
|
||||
onChange={(e) => {
|
||||
setBirthDate(e.target.value);
|
||||
if (e.target.value) setAgeFallback('');
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Wenn bekannt – das Alter wird automatisch berechnet.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{/* Fallback Alter */}
|
||||
{!birthDate && (
|
||||
<div className="form-group">
|
||||
<label>Alter (geschätzt)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={ageFallback}
|
||||
onChange={(e) => setAgeFallback(e.target.value)}
|
||||
placeholder="z. B. 3"
|
||||
/>
|
||||
<small>Optional – eine grobe Angabe reicht.</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geschlecht */}
|
||||
<div className="form-group">
|
||||
<label>Geschlecht</label>
|
||||
<select
|
||||
value={gender}
|
||||
onChange={(e) =>
|
||||
setGender(e.target.value as 'Männlich' | 'Weiblich' | 'Unbekannt')
|
||||
}
|
||||
>
|
||||
<option value="Unbekannt">Unbekannt</option>
|
||||
<option value="Männlich">Männlich</option>
|
||||
<option value="Weiblich">Weiblich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div style={{ display: 'grid', gap: '0.4rem' }}>
|
||||
<strong>Kurzbeschreibung (optional)</strong>
|
||||
<div className="form-group">
|
||||
<label>Kurzbeschreibung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder="Alter, Charakter, Besonderheiten …"
|
||||
rows={3}
|
||||
style={{
|
||||
padding: '0.8rem',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
placeholder="Charakter, Besonderheiten, Alter …"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sichtbarkeit */}
|
||||
<div className="form-group">
|
||||
<label>Sichtbarkeit</label>
|
||||
<select
|
||||
value={visibility}
|
||||
onChange={(e) =>
|
||||
setVisibility(e.target.value as 'public' | 'followers')
|
||||
}
|
||||
>
|
||||
<option value="public">Öffentlich</option>
|
||||
<option value="followers">Nur für Abonnenten</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Meldungen */}
|
||||
{error && (
|
||||
<div style={{ background: '#f8d7da', padding: '0.7rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div style={{ background: '#d1e7dd', padding: '0.7rem' }}>
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
{success && <div className="form-success">{success}</div>}
|
||||
|
||||
{/* Aktionen */}
|
||||
<div style={{ display: 'flex', gap: '0.8rem' }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '0.8rem 1.2rem',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
backgroundColor: '#2E7D32',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="button-primary">
|
||||
Haustier speichern
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="button-danger"
|
||||
>
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
Generated
+233
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"name": "OnlyPets",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
|
||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user