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
admin 34ace67d1f feat(ui): finalize form controls, button states and select styling
- add disabled and loading states for all buttons
- align danger button radius and interaction with design system
- improve native select styling for consistent rounded appearance
- normalize focus, hover and active states for form controls
- ensure visual consistency across pets, posts and forms
2025-12-24 23:08:21 +01:00
admin ab88d6e1bc fix(ui): stabilize post preview modal and cleanup global styles
- fix post preview modal height and scrolling (comments fully visible)
- prevent bottom navigation from overlapping modal content
- cleanup duplicate global CSS definitions
- normalize spacing and overflow behavior across feed and explore
- improve overall layout stability on mobile devices
2025-12-24 22:33:47 +01:00
admin 6ebbd5d457 feat(ui): global redesign, unified layout and personalized feed
- introduce global design system via CSS variables
- modern green-based color palette with improved readability
- unify Feed, Explore and Profile visual language
- add discovery posts visual highlight via border (no text shift)
- improve card depth, shadows and container consistency
- stabilize personalized feed logic (subscriptions + discovery)
- prepare UI for future theming without TSX changes
2025-12-24 15:52:34 +01:00
44 changed files with 5700 additions and 1204 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:
+111 -213
View File
@@ -1,242 +1,140 @@
'use client';
import { useState, useEffect } from 'react';
import CommentModal from '../CommentModal/CommentModal';
import { useRef } from 'react';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
type Comment = {
export type PostCardData = {
id: number;
author: string;
petName: string;
petType: 'Hund' | 'Katze';
authorName: string;
createdAt: string;
text: string;
};
type Media = {
type: 'image' | 'video';
url: string;
aspect: '1:1' | '9:16';
likesCount: number;
commentsCount: number;
};
type PostCardProps = {
petName: string;
petType: 'Hund' | 'Katze';
ownerName?: string;
text: string;
createdAt: string;
media?: Media;
likes: number;
likedByCurrentUser?: boolean;
commentsCount: number;
commentsList: Comment[];
post: PostCardData;
isDiscovery?: boolean
onOpenPreview: (post: PostCardData) => void;
onCommentClick?: (post: PostCardData) => void;
};
// 🔐 Simulierter eingeloggter User
const CURRENT_USER_ID = 1;
export default function PostCard({
petName,
petType,
ownerName,
text,
createdAt,
media,
likes,
likedByCurrentUser,
commentsCount,
commentsList,
post,
onOpenPreview,
onCommentClick,
isDiscovery,
}: PostCardProps) {
const [showComments, setShowComments] = useState(false);
const [showMediaFullscreen, setShowMediaFullscreen] = useState(false);
const likeRef = useRef<HTMLButtonElement | null>(null);
const commentRef = useRef<HTMLButtonElement | null>(null);
const isBirthday = post.petBirthDate && isBirthdayToday(post.petBirthDate);
const [localLikes, setLocalLikes] = useState(likes);
const [liked, setLiked] = useState(!!likedByCurrentUser);
const isSquare = media?.aspect === '1:1';
// ⛔ Scroll sperren bei Vollbild
useEffect(() => {
if (showMediaFullscreen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}, [showMediaFullscreen]);
function animate(
ref: React.RefObject<HTMLButtonElement>,
animation: string
) {
if (!ref.current) return;
const el = ref.current;
el.style.animation = animation;
el.addEventListener(
'animationend',
() => {
el.style.animation = '';
},
{ once: true }
);
}
return (
<>
<article
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
backgroundColor: 'white',
overflow: 'hidden',
}}
<article
className={`postcard
${isDiscovery ? 'postcard--discovery' : ''}
${isBirthday ? 'postcard--birthday' : ''}
`}
>
{/* Klickbarer Bereich → Preview */}
<div
className="postcard-main"
onClick={() => onOpenPreview(post)}
>
{/* HEADER */}
<header style={{ padding: '1rem', display: 'grid', gap: '0.2rem' }}>
<div style={{ fontWeight: 800, fontSize: '1.1rem' }}>
{petName}{' '}
<span style={{ fontWeight: 500, color: '#666' }}>
({petType})
</span>
</div>
<div className="postcard-media" />
<div style={{ fontSize: '0.8rem', color: '#777' }}>
{createdAt}
{ownerName && (
<>
{' · '}
<span style={{ fontStyle: 'italic' }}>
verwaltet von {ownerName}
</span>
</>
)}
</div>
</header>
<div className="postcard-content">
<div className="postcard-header">
{isDiscovery && (
<div className="post-discovery-wrapper">
</div>
)}
{/* MEDIA */}
{media && (
<div
onClick={() => setShowMediaFullscreen(true)}
style={{
width: '100%',
aspectRatio: isSquare ? '1 / 1' : '9 / 16',
backgroundColor: '#000',
cursor: 'pointer',
}}
>
{media.type === 'image' && (
<img
src={media.url}
alt={petName}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
<div className="postcard-title-row">
<strong className="postcard-title">
{post.petName}
</strong>
{media.type === 'video' && (
<video
src={media.url}
muted
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
</div>
)}
<span className="postcard-meta">
· {post.petType}
{post.petBirthDate && ` · ${formatPetAge(post.petBirthDate)}`}
· {post.authorName}
· {post.createdAt}
</span>
{/* TEXT */}
<div style={{ padding: '1rem', fontSize: '0.95rem' }}>
{text}
{isBirthday && (
<span
className="birthday-dot"
title="Heute ist Geburtstag 🎉"
aria-label="Geburtstag"
>
🎂
</span>
)}
</div>
</div>
<p className="postcard-text">{post.text}</p>
</div>
</div>
{/* FOOTER */}
<footer
style={{
padding: '0 1rem 1rem',
display: 'flex',
gap: '1.2rem',
fontSize: '0.85rem',
{/* Interaktionen */}
<div className="postcard-actions">
<button
ref={likeRef}
type="button"
className="postcard-action"
onClick={(e) => {
e.stopPropagation();
animate(likeRef, 'heart-pop 0.3s ease');
// später: Like-Logik
}}
>
<button
type="button"
onClick={() => {
setLiked(!liked);
setLocalLikes((prev) => (liked ? prev - 1 : prev + 1));
<span aria-hidden>❤️</span>
<span>{post.likesCount}</span>
</button>
console.log('LIKE', {
byUser: CURRENT_USER_ID,
pet: petName,
});
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontWeight: 600,
color: liked ? '#d32f2f' : '#555',
}}
>
❤️ {localLikes}
</button>
<button
type="button"
onClick={() => setShowComments(true)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontWeight: 600,
color: '#2E7D32',
}}
>
💬 {commentsCount} Kommentare
</button>
</footer>
</article>
{/* 💬 Kommentare */}
{showComments && (
<CommentModal
comments={commentsList}
onClose={() => setShowComments(false)}
/>
)}
{/* 🖼️ MEDIA FULLSCREEN */}
{showMediaFullscreen && media && (
<div
onClick={() => setShowMediaFullscreen(false)}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
cursor: 'zoom-out',
<button
ref={commentRef}
type="button"
className="postcard-action"
onClick={(e) => {
e.stopPropagation();
animate(commentRef, 'comment-wiggle 0.25s ease');
onCommentClick?.(post);
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
}}
>
{media.type === 'image' && (
<img
src={media.url}
alt={petName}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
)}
{media.type === 'video' && (
<video
src={media.url}
controls
autoPlay
style={{
maxWidth: '100%',
maxHeight: '100%',
}}
/>
)}
</div>
</div>
)}
</>
<span aria-hidden>💬</span>
<span>{post.commentsCount}</span>
</button>
</div>
</article>
);
}
+90 -335
View File
@@ -1,26 +1,16 @@
'use client';
import { useRef, useState } from 'react';
import PostCard, {
PostCardData,
} from '@/app/components/PostCard/PostCard';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
type ExplorePet = {
id: number;
name: string;
type: 'Hund' | 'Katze';
};
/* =======================
MOCK DATA
======================= */
type ExplorePost = {
id: number;
petId: number;
petName: string;
petType: 'Hund' | 'Katze';
authorName: string;
createdAt: string;
text: string;
likesCount: number;
commentsCount: number;
};
const trendingPets: ExplorePet[] = [
const trendingPets = [
{ id: 1, name: 'Luna', type: 'Katze' },
{ id: 2, name: 'Bello', type: 'Hund' },
{ id: 3, name: 'Milo', type: 'Katze' },
@@ -29,10 +19,9 @@ const trendingPets: ExplorePet[] = [
{ id: 6, name: 'Leo', type: 'Hund' },
];
const latestPosts: ExplorePost[] = [
const latestPosts: PostCardData[] = [
{
id: 101,
petId: 1,
petName: 'Luna',
petType: 'Katze',
authorName: 'Anna',
@@ -43,7 +32,6 @@ const latestPosts: ExplorePost[] = [
},
{
id: 102,
petId: 2,
petName: 'Bello',
petType: 'Hund',
authorName: 'Markus',
@@ -54,7 +42,6 @@ const latestPosts: ExplorePost[] = [
},
{
id: 103,
petId: 3,
petName: 'Milo',
petType: 'Katze',
authorName: 'Sophie',
@@ -65,7 +52,6 @@ const latestPosts: ExplorePost[] = [
},
{
id: 104,
petId: 4,
petName: 'Rocky',
petType: 'Hund',
authorName: 'Timo',
@@ -76,15 +62,20 @@ const latestPosts: ExplorePost[] = [
},
];
/* =======================
PAGE
======================= */
export default function ExplorePage() {
const [activeFilter, setActiveFilter] = useState<
'ALLE' | 'HUND' | 'KATZE'
>('ALLE');
const [activeFilter, setActiveFilter] =
useState<'ALLE' | 'HUND' | 'KATZE'>('ALLE');
const [previewPost, setPreviewPost] = useState<ExplorePost | null>(null);
const [openComments, setOpenComments] = useState(false);
const [previewPost, setPreviewPost] =
useState<PostCardData | null>(null);
// Swipe-Down Handling fürs Modal
const [openComments, setOpenComments] = useState(false);
// Swipe-Down fürs Modal
const startY = useRef<number | null>(null);
const filteredPosts = latestPosts.filter((p) => {
@@ -96,90 +87,46 @@ const [openComments, setOpenComments] = useState(false);
return (
<>
{/* ================= HEADER / FILTER ================= */}
<section
style={{
padding: '1rem',
display: 'grid',
gap: '1rem',
maxWidth: '720px',
margin: '0 auto',
}}
>
<section className="explore-header">
<header>
<h1 style={{ marginBottom: '0.3rem' }}>Entdecken</h1>
<p style={{ margin: 0, color: '#666' }}>
Neue Tiere, Trends & besondere Momente
</p>
<h1>Entdecken</h1>
<p>Neue Tiere, Trends & besondere Momente</p>
</header>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="explore-filter">
{[
{ key: 'ALLE', label: 'Alle' },
{ key: 'HUND', label: 'Hunde' },
{ key: 'KATZE', label: 'Katzen' },
].map((f) => {
const active = activeFilter === f.key;
return (
<button
key={f.key}
onClick={() =>
setActiveFilter(f.key as 'ALLE' | 'HUND' | 'KATZE')
}
style={{
padding: '0.45rem 0.9rem',
borderRadius: '999px',
border: active ? 'none' : '1px solid #ccc',
backgroundColor: active ? '#2E7D32' : 'white',
color: active ? 'white' : 'inherit',
fontSize: '0.75rem',
fontWeight: active ? 600 : 400,
cursor: 'pointer',
transform: active ? 'scale(1.05)' : 'scale(1)',
transition:
'background-color 0.2s ease, transform 0.15s ease',
}}
>
{f.label}
</button>
);
})}
].map((f) => (
<button
key={f.key}
className={
activeFilter === f.key
? 'filter-button active'
: 'filter-button'
}
onClick={() =>
setActiveFilter(f.key as 'ALLE' | 'HUND' | 'KATZE')
}
>
{f.label}
</button>
))}
</div>
</section>
{/* ================= BELIEBTE TIERE ================= */}
<section style={{ paddingLeft: '1rem', marginBottom: '1.5rem' }}>
<strong style={{ display: 'block', marginBottom: '0.6rem' }}>
Beliebte Tiere
</strong>
<section className="explore-trending">
<strong>Beliebte Tiere</strong>
<div
className="hide-scrollbar"
style={{
display: 'flex',
gap: '0.8rem',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<div className="trending-scroll hide-scrollbar">
{trendingPets.map((pet) => (
<div key={pet.id} style={{ width: '84px', flex: '0 0 auto' }}>
<div
style={{
width: '84px',
height: '84px',
borderRadius: '18px',
backgroundColor: '#f2f2f2',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.4rem',
}}
>
🐾
</div>
<div style={{ marginTop: '0.4rem', fontSize: '0.72rem' }}>
<div key={pet.id} className="trending-item">
<div className="trending-avatar">🐾</div>
<div className="trending-meta">
<strong>{pet.name}</strong>
<div style={{ color: '#777' }}>{pet.type}</div>
<span>{pet.type}</span>
</div>
</div>
))}
@@ -187,151 +134,23 @@ const [openComments, setOpenComments] = useState(false);
</section>
{/* ================= POSTS ================= */}
<section
style={{
padding: '0 1rem 1rem',
maxWidth: '720px',
margin: '0 auto',
}}
>
<strong style={{ display: 'block', marginBottom: '0.6rem' }}>
Neueste Beiträge
</strong>
<section className="explore-posts">
<strong>Neueste Beiträge</strong>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
<div className="post-list">
{filteredPosts.map((post) => (
<article
<PostCard
key={post.id}
style={{
border: '1px solid #eee',
borderRadius: '18px',
overflow: 'hidden',
backgroundColor: 'white',
post={post}
onOpenPreview={(p) => {
setOpenComments(false);
setPreviewPost(p);
}}
>
{/* ✅ Nur dieser Bereich öffnet das Modal */}
<div
onClick={() => setPreviewPost(post)}
style={{ cursor: 'pointer' }}
>
<div
style={{
width: '100%',
aspectRatio: '3 / 2',
backgroundColor: '#eaeaea',
}}
/>
<div style={{ padding: '0.8rem', display: 'grid', gap: '0.35rem' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '0.6rem',
}}
>
<div>
<strong>{post.petName}</strong>{' '}
<span style={{ color: '#777', fontSize: '0.8rem' }}>
· {post.petType} · {post.authorName}
</span>
</div>
<span
style={{
color: '#777',
fontSize: '0.8rem',
whiteSpace: 'nowrap',
}}
>
{post.createdAt}
</span>
</div>
<p style={{ margin: 0, fontSize: '0.9rem' }}>{post.text}</p>
</div>
</div>
{/* ✅ Interaktionszeile (öffnet KEIN Modal) */}
<div
style={{
padding: '0 0.8rem 0.8rem',
display: 'flex',
gap: '1rem',
fontSize: '0.85rem',
color: '#555',
}}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const el = e.currentTarget;
el.style.animation = 'heart-pop 0.3s ease';
el.addEventListener(
'animationend',
() => {
el.style.animation = '';
},
{ once: true }
);
console.log('LIKE CLICK', post.id);
}}
style={{
border: 'none',
background: 'transparent',
padding: 0,
cursor: 'pointer',
display: 'flex',
gap: '0.35rem',
alignItems: 'center',
color: 'inherit',
}}
>
<span aria-hidden>❤️</span>
<span>{post.likesCount}</span>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
// Animation
const el = e.currentTarget;
el.style.animation = 'comment-wiggle 0.25s ease';
el.addEventListener(
'animationend',
() => {
el.style.animation = '';
},
{ once: true }
);
// 👉 LOGIK
setOpenComments(true);
setPreviewPost(post);
}}
style={{
border: 'none',
background: 'transparent',
padding: 0,
cursor: 'pointer',
display: 'flex',
gap: '0.35rem',
alignItems: 'center',
color: 'inherit',
}}
>
<span aria-hidden>💬</span>
<span>{post.commentsCount}</span>
</button>
</div>
</article>
onCommentClick={(p) => {
setOpenComments(true);
setPreviewPost(p);
}}
/>
))}
</div>
</section>
@@ -339,127 +158,63 @@ const [openComments, setOpenComments] = useState(false);
{/* ================= PREVIEW MODAL ================= */}
{previewPost && (
<div
onClick={() => setPreviewPost(null)}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
zIndex: 50,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-end',
className="post-preview-overlay"
onClick={() => {
setPreviewPost(null);
setOpenComments(false);
}}
>
<div
className="post-preview-modal"
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => (startY.current = e.touches[0].clientY)}
onTouchStart={(e) =>
(startY.current = e.touches[0].clientY)
}
onTouchEnd={(e) => {
if (
startY.current &&
e.changedTouches[0].clientY - startY.current > 80
) {
setPreviewPost(null);
setOpenComments(false);
setOpenComments(false);
}
}}
style={{
backgroundColor: 'white',
width: '100%',
maxWidth: '720px',
borderTopLeftRadius: '22px',
borderTopRightRadius: '22px',
padding: '1rem',
animation: 'slideUp 0.25s ease',
paddingBottom: '5rem',
}}
>
<div
style={{
width: '36px',
height: '4px',
borderRadius: '999px',
backgroundColor: '#ccc',
margin: '0 auto 0.8rem',
}}
/>
<div className="modal-handle" />
<div
style={{
width: '100%',
aspectRatio: '3 / 2',
backgroundColor: '#eaeaea',
borderRadius: '14px',
marginBottom: '0.8rem',
}}
/>
<div className="modal-media" />
<strong>{previewPost.petName}</strong>{' '}
<span style={{ color: '#777', fontSize: '0.85rem' }}>
<span className="modal-meta">
· {previewPost.petType} · {previewPost.authorName}
</span>
<p style={{ marginTop: '0.6rem' }}>{previewPost.text}</p>
{openComments && (
<div
style={{
marginTop: '1rem',
paddingTop: '0.8rem',
borderTop: '1px solid #eee',
}}
>
<strong style={{ fontSize: '0.9rem' }}>Kommentare</strong>
<p className="modal-text">{previewPost.text}</p>
<div
style={{
marginTop: '0.6rem',
fontSize: '0.85rem',
color: '#666',
}}
>
Noch keine Kommentare.
</div>
<div className="modal-stats">
<span>❤️ {previewPost.likesCount}</span>
<span>💬 {previewPost.commentsCount}</span>
</div>
<input
placeholder="Kommentar schreiben…"
style={{
marginTop: '0.6rem',
width: '100%',
padding: '0.6rem 0.8rem',
borderRadius: '999px',
border: '1px solid #ccc',
fontSize: '0.85rem',
}}
autoFocus
/>
</div>
)}
{openComments && (
<div className="modal-comments">
<strong>Kommentare</strong>
<p className="modal-comment-empty">
Noch keine Kommentare.
</p>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '1rem',
}}
>
<div style={{ fontSize: '0.9rem', color: '#555', display: 'flex', gap: '1rem' }}>
<span>❤️ {previewPost.likesCount}</span>
<span>💬 {previewPost.commentsCount}</span>
<input
className="modal-comment-input"
placeholder="Kommentar schreiben…"
autoFocus
/>
</div>
)}
<div className="modal-actions">
<a
href={`/post/${previewPost.id}`}
style={{
padding: '0.5rem 0.9rem',
borderRadius: '999px',
backgroundColor: '#2E7D32',
color: 'white',
textDecoration: 'none',
fontSize: '0.85rem',
fontWeight: 600,
}}
className="modal-open-post"
>
Zum Beitrag
</a>
+208 -149
View File
@@ -1,179 +1,238 @@
'use client';
import { useState } from 'react';
import PostCard from '../components/PostCard/PostCard';
import FreshPostsSlider from '../components/FreshPostsSlider/FreshPostsSlider';
import { useRef, useState } from 'react';
import PostCard, {
PostCardData,
} from '@/app/components/PostCard/PostCard';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
/* ---------------- TYPES ---------------- */
/* =======================
MOCK: USER KONTEXT
======================= */
type Comment = {
id: number;
author: string;
text: string;
};
type Media = {
type: 'image' | 'video';
url: string;
aspect: '1:1' | '9:16';
};
type FeedPost = {
id: number;
// 🐾 Tier (Absender)
petId: number;
petName: string;
petType: 'Hund' | 'Katze';
// 👤 Admin (sekundär)
ownerId: number;
ownerName: string;
// 📝 Inhalt
text: string;
createdAt: string;
// 📸 / 🎥 Media
media?: Media;
// ❤️ Interaktion
likes: number;
//likedByCurrentUser: number; // ✅ FEHLTE
commentsCount: number;
commentsList: Comment[];
};
type FeedAdmin = {
id: number;
name: string;
avatar: string;
};
/* ---------------- MOCK ADMINS ---------------- */
const mockAdmins: FeedAdmin[] = [
const myPets = [
{
id: 1,
name: 'Anna',
avatar: 'https://i.pravatar.cc/100?img=12',
},
{
id: 2,
name: 'Markus',
avatar: 'https://i.pravatar.cc/100?img=32',
petType: 'Katze',
breed: 'Bengal',
ageMonths: 24,
},
];
/* ---------------- MOCK POSTS ---------------- */
const followedPetIds = [1, 3, 5];
const mockPosts: FeedPost[] = [
/* =======================
MOCK: POSTS
======================= */
type FeedPost = PostCardData & {
petId: number;
breed: string;
ageMonths: number;
};
const allPosts: FeedPost[] = [
{
id: 1,
id: 401,
petId: 1,
petName: 'Luna',
petType: 'Katze',
ownerId: 1,
ownerName: 'Anna',
text: 'Luna genießt die Sonne auf dem Balkon ☀️',
createdAt: 'vor 2 Stunden',
media: {
type: 'image',
url: 'https://placekitten.com/600/600',
aspect: '1:1',
},
likes: 12,
breed: 'Bengal',
ageMonths: 26,
authorName: 'Anna',
createdAt: 'vor 10 Minuten',
text: 'Luna liebt den neuen Kratzbaum.',
likesCount: 8,
commentsCount: 2,
commentsList: [
{ id: 1, author: 'Lisa', text: 'Wie süß 😍' },
{ id: 2, author: 'Tom', text: 'Typisch Katze!' },
],
},
{
id: 2,
id: 402,
petId: 2,
petName: 'Bello',
petName: 'Rocky',
petType: 'Hund',
ownerId: 2,
ownerName: 'Markus',
text: 'Spaziergang im Park – jemand hatte sehr viel Energie 🐕',
createdAt: 'heute Morgen',
media: {
type: 'video',
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
aspect: '9:16',
},
likes: 20,
commentsCount: 0,
commentsList: [],
breed: 'Labrador',
ageMonths: 30,
authorName: 'Tim',
createdAt: 'vor 1 Stunde',
text: 'Rocky war schwimmen 🐕‍🦺',
likesCount: 15,
commentsCount: 4,
},
{
id: 403,
petId: 3,
petName: 'Milo',
petType: 'Katze',
breed: 'Bengal',
ageMonths: 22,
authorName: 'Sophie',
createdAt: 'heute',
text: 'Milo entdeckt den Balkon.',
likesCount: 11,
commentsCount: 3,
},
{
id: 404,
petId: 4,
petName: 'Nala',
petType: 'Katze',
breed: 'Maine Coon',
ageMonths: 40,
authorName: 'Laura',
createdAt: 'gestern',
text: 'Nala beobachtet alles ganz ruhig.',
likesCount: 6,
commentsCount: 1,
},
];
/* ---------------- PAGE ---------------- */
/* =======================
RELEVANZ LOGIK
======================= */
function relevanceScore(post: FeedPost) {
let score = 0;
myPets.forEach((pet) => {
if (post.petType === pet.petType) score += 3;
if (post.breed === pet.breed) score += 5;
if (Math.abs(post.ageMonths - pet.ageMonths) <= 6) score += 2;
});
score += Math.min(post.likesCount / 10, 3);
score += Math.min(post.commentsCount / 5, 2);
return score;
}
/* =======================
PAGE
======================= */
export default function FeedPage() {
const [selectedOwnerId, setSelectedOwnerId] = useState<number | null>(null);
const [previewPost, setPreviewPost] =
useState<PostCardData | null>(null);
const [openComments, setOpenComments] = useState(false);
const visiblePosts = selectedOwnerId
? mockPosts.filter((post) => post.ownerId === selectedOwnerId)
: mockPosts;
const startY = useRef<number | null>(null);
/* =======================
FEED ZUSAMMENSTELLEN
======================= */
const subscribedPosts = allPosts.filter((post) =>
followedPetIds.includes(post.petId)
);
const discoveryPosts = allPosts
.filter((post) => !followedPetIds.includes(post.petId))
.sort((a, b) => relevanceScore(b) - relevanceScore(a));
const FEED_SIZE = 20;
const DISCOVERY_RATIO = 0.15;
const discoveryCount = Math.max(
1,
Math.round(FEED_SIZE * DISCOVERY_RATIO)
);
const finalFeed = [
...subscribedPosts
.slice(0, FEED_SIZE - discoveryCount)
.map((p) => ({ ...p, isDiscovery: false })),
...discoveryPosts
.slice(0, discoveryCount)
.map((p) => ({ ...p, isDiscovery: true })),
];
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
{/* HEADER */}
<header>
<h1 style={{ marginBottom: '0.3rem' }}>Feed</h1>
<p style={{ margin: 0, color: '#555' }}>
Neueste Beiträge aus der OnlyPets-Community
</p>
</header>
{/* 🔄 Frische Tier-Admins Slider */}
<FreshPostsSlider
users={mockAdmins}
onSelectUser={setSelectedOwnerId}
/>
{/* 📰 FEED */}
<section style={{ display: 'grid', gap: '1.5rem' }}>
{visiblePosts.length === 0 && (
<p style={{ color: '#777' }}>
Keine Beiträge von diesen Tier-Admins.
</p>
)}
{visiblePosts.map((post) => (
<PostCard
key={post.id}
petName={post.petName}
petType={post.petType}
ownerName={post.ownerName}
text={post.text}
createdAt={post.createdAt}
media={post.media}
likes={post.likes}
commentsCount={post.commentsCount}
commentsList={post.commentsList}
/>
))}
<>
{/* ================= HEADER ================= */}
<section className="feed-header">
<header>
<h1>Feed</h1>
<p>Beiträge deiner Tiere & Abos</p>
</header>
</section>
</section>
{/* ================= POSTS ================= */}
<section className="feed-posts">
<div className="post-list">
{finalFeed.map((post) => (
<PostCard
key={post.id}
post={post}
isDiscovery={post.isDiscovery}
onOpenPreview={(p) => {
setOpenComments(false);
setPreviewPost(p);
}}
onCommentClick={(p) => {
setOpenComments(true);
setPreviewPost(p);
}}
/>
))}
</div>
</section>
{/* ================= PREVIEW MODAL ================= */}
{previewPost && (
<div
className="post-preview-overlay"
onClick={() => {
setPreviewPost(null);
setOpenComments(false);
}}
>
<div
className="post-preview-modal"
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) =>
(startY.current = e.touches[0].clientY)
}
onTouchEnd={(e) => {
if (
startY.current &&
e.changedTouches[0].clientY - startY.current > 80
) {
setPreviewPost(null);
setOpenComments(false);
}
}}
>
<div className="modal-handle" />
<div className="modal-media" />
<strong>{previewPost.petName}</strong>{' '}
<span className="modal-meta">
· {previewPost.petType} · {previewPost.authorName}
</span>
<p className="modal-text">{previewPost.text}</p>
<div className="modal-stats">
<span>❤️ {previewPost.likesCount}</span>
<span>💬 {previewPost.commentsCount}</span>
</div>
{openComments && (
<div className="modal-comments">
<strong>Kommentare</strong>
<p className="modal-comment-empty">
Noch keine Kommentare.
</p>
<input
className="modal-comment-input"
placeholder="Kommentar schreiben…"
autoFocus
/>
</div>
)}
</div>
</div>
)}
</>
);
}
+665 -14
View File
@@ -1,23 +1,51 @@
@import "tailwindcss";
* {
box-sizing: border-box;
}
:root {
--background: #ffffff;
--foreground: #171717;
/* ===== COLORS ===== */
--color-primary: #2e7d32;
--color-primary-soft: #e6efe9;
--color-bg: #f6faf7;
--color-surface: #ffffff;
--color-text-main: #1f2d24;
--color-text-secondary: #5f6f66;
--color-text-muted: #8a9b92;
--color-border: rgba(0, 0, 0, 0.06);
/* ===== SHADOWS ===== */
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.05);
--shadow-md: 0 8px 20px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.12);
/* ===== RADIUS ===== */
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
/* ===== SPACING ===== */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
}
html, body {
background: var(--color-bg);
color: var(--color-text-main);
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.5;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
@@ -96,4 +124,627 @@ body {
transform: translateY(0);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.postcard {
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.postcard:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.postcard-content {
font-size: 0.95rem;
color: var(--color-text-secondary);
}
.postcard-actions {
display: flex;
gap: var(--space-md);
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.postcard-action {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
user-select: none;
}
.postcard-action:hover {
color: var(--color-text-main);
}
.postcard--discovery {
border: 1px solid var(--color-primary);
box-shadow:
0 0 0 1px rgba(236, 214, 15, 0.925),
var(--shadow-sm);
}
.feed-header,
.explore-header {
padding: var(--space-lg) var(--space-md) var(--space-md);
}
/*.feed-header p,
.explore-header p {
color: var(--color-text-secondary);
margin: 0.25rem 0 0;
}*/
.post-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding-bottom: calc(var(--space-xl) + 60px);
}
.fresh-scroll,
.trending-scroll {
display: flex;
gap: var(--space-sm);
overflow-x: auto;
padding: var(--space-sm) var(--space-md);
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.fresh-item,
.trending-item {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: var(--space-sm);
min-width: 80px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
}
.profile-header {
display: flex;
gap: var(--space-lg);
padding: var(--space-lg);
margin: var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
.profile-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--color-primary-soft);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
}
.profile-info h1 {
font-size: 1.3rem;
margin: 0;
}
.profile-username {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.profile-bio {
margin-top: var(--space-sm);
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.profile-stats {
display: flex;
gap: var(--space-lg);
margin-top: var(--space-md);
}
.profile-stats div {
text-align: center;
}
.profile-stats strong {
font-size: 1.05rem;
font-weight: 600;
}
.profile-stats span {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.profile-pets {
margin-top: var(--space-lg);
padding: 0 var(--space-md);
}
.profile-pets h2 {
font-size: 1.1rem;
margin-bottom: var(--space-sm);
}
.pets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--space-md);
}
.pet-card {
background: var(--color-surface);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
padding: var(--space-sm);
text-align: center;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.profile-posts {
margin-top: var(--space-xl);
}
.profile-posts h2 {
font-size: 1.1rem;
margin: 0 var(--space-md) var(--space-sm);
}
.pet-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.post-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
justify-content: center;
align-items: flex-end;
z-index: 50;
overflow: hidden;
}
.post-preview-modal {
background: var(--color-surface);
width: 100%;
max-width: 600px;
/* 🔥 DAS FEHLT */
max-height: calc(100vh - 70px); /* Platz für Statusbar */
overflow-y: auto;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: var(--space-lg);
box-shadow: var(--shadow-lg);
padding-bottom: calc(var(--space-xl) + 60px);
}
.explore-filter {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-md);
}
.filter-button {
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 0.85rem;
color: var(--color-text-secondary);
cursor: pointer;
}
.filter-button.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.explore-header,
.explore-trending,
.explore-posts {
padding: 0 var(--space-md);
}
.explore-trending,
.explore-posts {
margin-top: var(--space-lg);
}
.explore-header h1 {
font-size: 1.4rem;
margin-bottom: 0.2rem;
}
.explore-header p {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.explore-trending strong,
.explore-posts strong {
display: block;
margin-bottom: var(--space-sm);
}
.feed-header {
padding: var(--space-lg) var(--space-md) var(--space-md);
}
.feed-header h1 {
font-size: 1.4rem;
margin-bottom: 0.2rem;
}
.feed-header p {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.feed-posts {
margin-top: var(--space-md);
}
.feed-posts .postcard {
margin-bottom: var(--space-md);
}
.feed-header h1::after {
content: '•';
margin-left: 0.4rem;
color: var(--color-primary);
}
.pets-page {
padding-bottom: calc(var(--space-xl) + 60px); /* Abstand zur BottomNav */
}
.pets-header {
padding: var(--space-lg) var(--space-md) var(--space-md);
}
.pets-header h1 {
font-size: 1.4rem;
margin-bottom: 0.25rem;
}
.pets-header p {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 0;
}
.pets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-md);
padding: 0 var(--space-md);
}
.pet-card {
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
padding: var(--space-md);
text-align: center;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 0.3rem;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.pet-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.pet-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-primary-soft);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
margin: 0 auto var(--space-sm);
}
.pet-name {
font-size: 1rem;
font-weight: 600;
}
.pet-meta {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.pet-age {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.pets-actions {
margin-top: var(--space-xl);
padding: 0 var(--space-md);
}
.button-primary {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
padding: 0.8rem 1.2rem;
border-radius: 12px;
background: var(--color-primary);
color: white;
font-weight: 600;
text-decoration: none;
border: none;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease, transform 0.15s ease;
}
.button-primary:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.button-secondary {
display: inline-flex;
justify-content: center;
align-items: center;
padding: 0.8rem 1.2rem;
border-radius: 12px;
background: var(--color-surface);
color: var(--color-text-main);
border: 1px solid var(--color-border);
font-weight: 500;
cursor: pointer;
}
.button-danger {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
padding: 0.8rem 1.2rem;
border-radius: 12px; /* 🔴 gleiche Rundung wie grün */
background: #c62828;
color: white;
font-weight: 600;
border: none;
cursor: pointer;
text-decoration: none;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease, transform 0.15s ease;
}
.button-danger:hover {
background: #b71c1c;
box-shadow: var(--shadow-md);
}
.button-danger:active {
transform: scale(0.98);
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
border-radius: 999px;
background: var(--color-border);
color: var(--color-text-muted);
}
.badge-pet {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.postcard--discovery {
border: 1px solid var(--color-primary);
box-shadow:
0 0 0 1px rgba(46, 125, 50, 0.15),
var(--shadow-sm);
}
.empty-state {
padding: var(--space-xl);
margin: var(--space-md);
text-align: center;
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px dashed var(--color-border);
color: var(--color-text-secondary);
}
.empty-state strong {
display: block;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-main);
margin-bottom: 0.3rem;
}
.empty-state span {
font-size: 0.85rem;
line-height: 1.4;
}
.empty-state--explore {
border-style: solid;
}
.empty-state--pets {
border-style: dashed;
}
button:active,
.button-primary:active {
transform: scale(0.98);
}
a {
-webkit-tap-highlight-color: transparent;
}
section + section {
margin-top: var(--space-xl);
}
h1, h2, h3 {
line-height: 1.25;
}
.pet-detail-page {
padding-bottom: calc(var(--space-xl) + 60px); /* Platz für BottomNav */
}
.pet-detail-header {
display: flex;
gap: var(--space-lg);
padding: var(--space-lg);
margin: var(--space-md);
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
align-items: center;
}
.pet-detail-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--color-primary-soft);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
flex-shrink: 0;
}
.pet-detail-info h1 {
font-size: 1.4rem;
margin: 0;
}
.pet-detail-meta {
display: block;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-top: 0.2rem;
}
.pet-detail-owner {
display: block;
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 0.3rem;
}
.pet-detail-posts {
margin-top: var(--space-xl);
}
.pet-detail-posts h2 {
font-size: 1.1rem;
margin: 0 var(--space-md) var(--space-sm);
}
.container {
max-width: 720px;
margin: 0 auto;
}
.divider {
height: 1px;
background: var(--color-border);
margin: var(--space-lg) 0;
}
button:disabled,
.button-primary:disabled,
.button-secondary:disabled,
.button-danger:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.button-primary.is-loading,
.button-secondary.is-loading,
.button-danger.is-loading {
position: relative;
pointer-events: none;
color: transparent; /* Text ausblenden */
}
.button-primary.is-loading::after,
.button-secondary.is-loading::after,
.button-danger.is-loading::after {
content: '';
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: white;
border-radius: 50%;
position: absolute;
animation: spin 0.8s linear infinite;
}
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 100%;
padding: 0.7rem 2.4rem 0.7rem 0.9rem;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background-color: var(--color-surface);
color: var(--color-text-main);
font-size: 0.9rem;
line-height: 1.3;
box-shadow: var(--shadow-sm);
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24'%3E%3Cpath fill='%235f6f66' d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.9rem center;
}
select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(46, 125, 50, 0.15);
}
select:disabled {
background-color: var(--color-primary-soft);
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>
);
}
+99 -64
View File
@@ -1,27 +1,30 @@
'use client';
import { useParams } from 'next/navigation';
import PostCard from '@/app/components/PostCard/PostCard';
import {calculatePetAge, formatPetAge, isBirthdayToday,} from '@/app/lib/petAge';
type Comment = {
id: number;
author: string;
text: string;
};
type PetPost = {
id: number;
text: string;
createdAt: string;
likes: number;
commentsCount: number;
commentsList: Comment[];
};
type Pet = {
id: number;
name: string;
type: 'Hund' | 'Katze';
breed: string;
bio?: string;
posts: PetPost[];
age?: string;
owner: string;
};
type Post = {
id: number;
petId: number;
petName: string;
petType: 'Hund' | 'Katze';
author: string;
text: string;
createdAt: string;
likes: number;
comments: number;
};
const mockPets: Pet[] = [
@@ -29,76 +32,108 @@ const mockPets: Pet[] = [
id: 1,
name: 'Luna',
type: 'Katze',
breed: 'Bengal x Mix',
bio: 'Neugierig, verspielt und liebt Sonne.',
posts: [
{
id: 101,
text: 'Luna genießt die Sonne ☀️',
createdAt: 'vor 2 Stunden',
likes: 12,
commentsCount: 2,
commentsList: [
{ id: 1, author: 'Lisa', text: 'Wie süß 😍' },
{ id: 2, author: 'Tom', text: 'Typisch Bengal!' },
],
},
],
breed: 'Bengal',
age: '2 Jahre',
owner: 'Anna',
birthDate: '2021-06-15',
},
{
id: 2,
name: 'Bello',
type: 'Hund',
breed: 'Labrador Retriever x Mix',
bio: 'Immer gute Laune.',
posts: [],
breed: 'Labrador',
age: '4 Jahre',
owner: 'Markus',
birthDate: '2020-03-22',
},
];
type PageProps = {
params: Promise<{ id: string }>;
};
const mockPosts: Post[] = [
{
id: 101,
petId: 1,
petName: 'Luna',
petType: 'Katze',
author: 'Anna',
text: 'Luna liebt sonnige Nachmittage auf dem Balkon ☀️',
createdAt: 'vor 3 Stunden',
likes: 12,
comments: 3,
},
{
id: 102,
petId: 1,
petName: 'Luna',
petType: 'Katze',
author: 'Anna',
text: 'Heute gab es neue Spielsachen 🧶',
createdAt: 'gestern',
likes: 7,
comments: 1,
},
];
export default async function PetDetailPage({ params }: PageProps) {
const { id } = await params; // ✅ DAS ist der entscheidende Fix
const petId = Number(id);
console.log('PET ID:', petId);
export default function PetDetailPage() {
const params = useParams();
const petId = Number(params.id);
const pet = mockPets.find((p) => p.id === petId);
const posts = mockPosts.filter((p) => p.petId === petId);
if (!pet) {
return <p>Haustier nicht gefunden.</p>;
return (
<div className="empty-state">
<strong>Tier nicht gefunden</strong>
<span>Dieses Haustier existiert nicht oder wurde entfernt.</span>
</div>
);
}
return (
<section style={{ maxWidth: '600px', margin: '0 auto', display: 'grid', gap: '1.5rem' }}>
<header style={{ border: '1px solid #e5e5e5', borderRadius: '16px', padding: '1rem' }}>
<h1>{pet.name}</h1>
<div>{pet.type} · {pet.breed}</div>
{pet.bio && <p>{pet.bio}</p>}
<section className="pet-detail-page">
{/* Header */}
<header className="pet-detail-header">
<div className="pet-detail-avatar">🐾</div>
<div className="pet-detail-info">
<h1>{pet.name}</h1>
<span className="pet-detail-meta">
{pet.type} · {pet.breed}
{pet.birthDate && ` · ${formatPetAge(pet.birthDate)}`}
</span>
<span className="pet-detail-owner">
Betreut von {pet.owner}
</span>
</div>
</header>
<section style={{ display: 'grid', gap: '1.2rem' }}>
{/* Posts */}
<section className="pet-detail-posts">
<h2>Beiträge</h2>
{pet.posts.length === 0 && (
<p style={{ color: '#777' }}>Dieses Haustier hat noch keine Beiträge.</p>
{posts.length === 0 ? (
<div className="empty-state">
<strong>Noch keine Beiträge</strong>
<span>
Für {pet.name} wurden noch keine Beiträge erstellt.
</span>
</div>
) : (
<div className="post-list">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
isDiscovery={false}
/>
))}
</div>
)}
{pet.posts.map((post) => (
<PostCard
key={post.id}
petName={pet.name}
petType={pet.type}
author="Du"
text={post.text}
createdAt={post.createdAt}
likes={post.likes}
commentsCount={post.commentsCount}
commentsList={post.commentsList}
/>
))}
</section>
</section>
);
+134 -125
View File
@@ -2,37 +2,43 @@
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 [visibility, setVisibility] = useState<'public' | 'followers'>(
'public'
);
// UX States
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const breeds =
type === 'Hund'
? dogBreeds
: type === 'Katze'
? catBreeds
: [];
const breeds = type === 'Hund' ? dogBreeds : catBreeds;
const filteredBreeds = breeds.filter((b) =>
b.toLowerCase().includes(breedSearch.toLowerCase())
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
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;
}
@@ -42,120 +48,80 @@ export default function NewPetPage() {
}
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>
@@ -164,51 +130,94 @@ export default function NewPetPage() {
</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>
<Link href="/pets">Abbrechen</Link>
<button
type="button"
className="button-danger"
onClick={handleCancel}
>
Abbrechen
</button>
</div>
</form>
</section>
+51 -46
View File
@@ -1,75 +1,80 @@
'use client';
import Link from 'next/link';
type Pet = {
id: number;
name: string;
type: 'Hund' | 'Katze';
breed: string;
age?: string;
};
const mockPets: Pet[] = [
{ id: 1, name: 'Luna', type: 'Katze' },
{ id: 2, name: 'Bello', type: 'Hund' },
{
id: 1,
name: 'Luna',
type: 'Katze',
breed: 'Bengal',
age: '2 Jahre',
},
{
id: 2,
name: 'Bello',
type: 'Hund',
breed: 'Labrador',
age: '4 Jahre',
},
{
id: 3,
name: 'Milo',
type: 'Katze',
breed: 'Europäisch Kurzhaar',
},
];
export default function PetsPage() {
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<h1 style={{ marginBottom: '0.5rem' }}>Meine Haustiere</h1>
<p style={{ margin: 0, color: '#555' }}>
Verwalte deine Hunde und Katzen
<section className="pets-page">
{/* Header */}
<header className="pets-header">
<h1>Meine Tiere</h1>
<p>
Verwalte deine Haustiere – Beiträge sind immer einem Tier zugeordnet.
</p>
</header>
<section style={{ display: 'grid', gap: '1rem' }}>
{/* Pets Grid */}
<div className="pets-grid">
{mockPets.map((pet) => (
<Link
key={pet.id}
href={`/pets/${pet.id}`}
style={{
textDecoration: 'none',
color: 'inherit',
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
className="pet-card"
>
<div>
<strong>{pet.name}</strong>
<div style={{ fontSize: '0.9rem', color: '#555' }}>
{pet.type}
</div>
<div className="pet-avatar">
🐾
</div>
<span style={{ fontSize: '1.2rem' }}>›</span>
<strong className="pet-name">{pet.name}</strong>
<span className="pet-meta">
{pet.type} · {pet.breed}
</span>
{pet.age && (
<span className="pet-age">{pet.age}</span>
)}
</Link>
))}
</section>
</div>
<Link
href="/pets/new"
style={{
alignSelf: 'flex-start',
padding: '0.7rem 1rem',
borderRadius: '12px',
backgroundColor: '#2E7D32',
color: 'white',
textDecoration: 'none',
fontWeight: 600,
}}
>
➕ Haustier hinzufügen
</Link>
{/* Add Pet */}
<div className="pets-actions">
<Link href="/pets/new" className="button-primary">
+ Neues Tier hinzufügen
</Link>
</div>
</section>
);
}
+155 -1
View File
@@ -1,3 +1,157 @@
'use client';
import PostCard, {
PostCardData,
} from '@/app/components/PostCard/PostCard';
/* =======================
MOCK: USER
======================= */
const user = {
id: 1,
name: 'Laura',
username: '@laura_pets',
bio: 'Liebt Tiere, Natur & ruhige Momente.',
stats: {
pets: 2,
posts: 6,
follows: 18,
},
};
/* =======================
MOCK: PETS
======================= */
const pets = [
{
id: 1,
name: 'Nala',
type: 'Katze',
breed: 'Maine Coon',
age: '3 Jahre',
},
{
id: 2,
name: 'Rocky',
type: 'Hund',
breed: 'Labrador',
age: '4 Jahre',
},
];
/* =======================
MOCK: POSTS
======================= */
const posts: PostCardData[] = [
{
id: 501,
petName: 'Nala',
petType: 'Katze',
authorName: 'Laura',
createdAt: 'gestern',
text: 'Nala beobachtet alles ganz ruhig.',
likesCount: 6,
commentsCount: 1,
},
{
id: 502,
petName: 'Rocky',
petType: 'Hund',
authorName: 'Laura',
createdAt: 'vor 2 Tagen',
text: 'Rocky liebt lange Spaziergänge.',
likesCount: 9,
commentsCount: 2,
},
];
/* =======================
PAGE
======================= */
export default function ProfilePage() {
return <h1>Profil</h1>;
return (
<>
{/* ================= USER HEADER ================= */}
<section className="profile-header">
<div className="profile-avatar">
<span>👤</span>
</div>
<div className="profile-info">
<h1>{user.name}</h1>
<span className="profile-username">
{user.username}
</span>
{user.bio && (
<p className="profile-bio">
{user.bio}
</p>
)}
<div className="profile-stats">
<div>
<strong>{user.stats.pets}</strong>
<span>Tiere</span>
</div>
<div>
<strong>{user.stats.posts}</strong>
<span>Beiträge</span>
</div>
<div>
<strong>{user.stats.follows}</strong>
<span>Abos</span>
</div>
</div>
</div>
</section>
{/* ================= PETS ================= */}
<section className="profile-pets">
<h2>Meine Tiere</h2>
<div className="pets-grid">
{pets.map((pet) => (
<a
key={pet.id}
href={`/pets/${pet.id}`}
className="pet-card"
>
<div className="pet-avatar">🐾</div>
<strong className="pet-name">
{pet.name}
</strong>
<span className="pet-meta">
{pet.type} · {pet.breed}
</span>
<span className="pet-age">
{pet.age}
</span>
</a>
))}
</div>
</section>
{/* ================= POSTS ================= */}
<section className="profile-posts">
<h2>Beiträge</h2>
<div className="post-list">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
/>
))}
</div>
</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"
}
}