Author SHA1 Message Date
admin c0a814081d feat(backend): bootstrap NestJS app with dummy auth and health endpoint 2025-12-26 12:42:10 +01:00
admin fb4c442542 docs(auth): document dummy auth architecture 2025-12-26 12:03:55 +01:00
admin 122dad35ce feat(auth): add dummy auth foundation with global guard 2025-12-26 12:03:00 +01:00
admin 9253cac1b2 feat(backend): finalize OnlyPets backend architecture
Refs #3

Backend architecture for OnlyPets was finalized and hardened.

[Text wie oben]
2025-12-25 15:38:57 +01:00
admin 7759bd0a05 feat(backend): finalize pets logic, follow system, notifications and feed hardening 2025-12-25 13:30:15 +01:00
admin 27174bef18 feat(backend): add follow/unfollow endpoints and live follower counts 2025-12-25 13:12:56 +01:00
admin 0778f188bc feat(backend): add follow relation and enforce followers visibility in feed 2025-12-25 13:09:24 +01:00
admin aba22e2855 feat(feed): add birthday highlight for pets in feed and explore
- display subtle birthday marker on posts when pet has birthday
- highlight birthday posts with soft accent border
- reuse central petAge utilities for consistent logic
- preserve all existing post meta information (author, time)
- avoid layout shifts by using decorative indicators only
2025-12-25 12:45:14 +01:00
admin 13f2dedf13 feat(pets): add birthdate-based age system with birthday support
- introduce central pet age utilities (calculate, format, birthday check)
- replace static pet.age with dynamic birthDate logic
- display growing age correctly in pet detail view
- prepare foundation for birthday highlights and notifications
- fix duplicate imports and ensure stable build
2025-12-25 11:15:47 +01:00
42 changed files with 4390 additions and 407 deletions

No files matched your search

+19
View File
@@ -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/
+2741
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -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"
}
}
+10
View File
@@ -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"
+10
View File
@@ -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',
},
});
+16
View File
@@ -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();
}
}
+79
View File
@@ -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())
}
+137
View File
@@ -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();
});
+17
View File
@@ -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 {}
+25
View File
@@ -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
View File
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[];
}
+72
View File
@@ -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),
},
},
});
}
}
+14
View File
@@ -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(),
};
}
}
+13
View File
@@ -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,
});
}
}
+16
View File
@@ -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';
}
+6
View File
@@ -0,0 +1,6 @@
export class UpdatePetDto {
birthDate?: string; // ISO – einzige Altersquelle
bio?: string;
avatarUrl?: string;
visibility?: 'public' | 'followers';
}
+14
View File
@@ -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,
);
}
+119
View File
@@ -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,
},
});
}
}
+33
View File
@@ -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,
});
}
}
+156
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
import { RequestUser } from '../auth/types/request-user.type';
declare global {
namespace Express {
interface Request {
user?: RequestUser | null;
}
}
}
export {};
+23
View File
@@ -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,
};
}
+16
View File
@@ -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"]
}
+18
View File
@@ -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:
+31 -10
View File
@@ -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>
+1
View File
@@ -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
+1
View File
@@ -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
+29 -2
View File
@@ -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);
}
+34
View File
@@ -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`;
}
+162 -257
View File
@@ -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>
);
}
+11 -3
View File
@@ -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
View File
@@ -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>
+233
View File
@@ -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"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"devDependencies": {
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}