22 Commits
Author SHA1 Message Date
admin baadfe618c feat(feed): handle followers-only posts with locked UI (issue #246) 2026-01-02 15:35:52 +01:00
admin cc5db2b37e feat: enable CORS and add frontend API test page 2026-01-02 14:55:56 +01:00
admin 06e9240909 feat(backend): feed, posts & pets APIs frontend-ready with visibility enforcement 2026-01-02 14:45:40 +01:00
admin e1e1b7ab21 Fundament: Zentrale Auth-Leitlinie & RequestUser vereinheitlicht (#241)
- Guard-first Auth-Architektur dokumentiert
- RequestUser als einzige User-Wahrheit etabliert
- Keine Auth-Logik in Services
- Public-Routen explizit via @Public()
- Vorbereitung für JWT ohne Service-Änderungen
2026-01-01 14:12:49 +01:00
admin 831e6b0cbd Phase 2.1: Feed angebunden & Visibility serverseitig enforced (#245 #246)
- Echter /feed Endpoint angebunden (Mock entfernt)
- Visibility-Regeln serverseitig umgesetzt (public / followers)
- RequestUser als zentraler Auth-Typ vereinheitlicht
- Visibility-Leak behoben
2026-01-01 14:08:14 +01:00
admin b492ecdaa3 feat(frontend): stabilize feed mock, align PostCard types and polish UX 2025-12-27 12:57:05 +01:00
admin 09599ff7e9 chore(frontend): before ux polish 2025-12-27 12:35:58 +01:00
admin 5b66e57d2e feat(backend): add /feed endpoint mirrored from /posts
- Introduced FeedModule with controller and service
- /feed mirrors /posts 1:1 (baseline, deterministic)
- No discovery or random logic yet
- Prisma + DI fully wired and stable
- Backend now frontend-ready for feed consumption
2025-12-27 10:46:05 +01:00
admin 703470e501 feat(backend): activate posts feed with prisma + strict DI 2025-12-27 10:42:10 +01:00
admin 1081fc8f7f feat(backend): activate posts feed with prisma + strict DI 2025-12-27 10:42:06 +01:00
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
62 changed files with 9230 additions and 1205 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/
+129
View File
@@ -0,0 +1,129 @@
# 🧊 Backend Freeze – Frontend Ready
**Projekt:** OnlyPets
**Stand:** Backend eingefroren (Frontend-ready)
**Datum:** 27.12.2025
Ziel dieses Dokuments ist es, klar festzuhalten:
> Das Backend ist technisch und strukturell abgeschlossen,
> sodass die weitere Entwicklung primär im Frontend stattfinden kann.
Spezial-Features werden bewusst **getrennt** und **nachgelagert** umgesetzt.
---
## 🧱 1. Architektur & Infrastruktur
- NestJS stabil gebootstrapped
- Module sauber getrennt:
- Posts
- Feed
- Prisma
- Auth
- Dependency Injection stabil (keine offenen DI-Fehler)
- Prisma korrekt als Service eingebunden
- Strict TypeScript aktiv (keine `any`-Leaks)
- Ordnerstruktur final (kein späteres Refactoring notwendig)
**Status:** ✅ abgeschlossen
---
## 🔐 2. Auth-Grundlage (Phase 1)
- Globaler `DummyAuthGuard` aktiv
- `req.user` immer definiert (`null | FakeUser`)
- Rollenmodell vorbereitet (`USER | MOD | ADMIN`)
- `@Public()` Decorator vorhanden
- Guard-first-Architektur (keine Auth-Logik in Services)
> Echte Auth (JWT, Login) ist **bewusst nicht Teil** des Freeze-Zustands.
**Status:** ✅ abgeschlossen (Phase-1-Scope)
---
## 🗃️ 3. Datenmodell & Prisma
- Prisma Schema stabil
- Keine redundanten oder abgeleiteten Felder
- Alter wird **nicht gespeichert**
- `birthDate` ist Single Source of Truth
- Beziehungen konsistent:
- User ↔ Pet ↔ Post
- Seed reproduzierbar & idempotent
- DB-Zugriff ausschließlich über Services
**Status:** ✅ abgeschlossen
---
## 📰 4. Feed & Content (Frontend-relevant)
Aktive Endpoints:
- `GET /posts`
- `GET /feed`
Eigenschaften:
- `/feed` ist 1:1 aus `/posts` gespiegelt
- Deterministischer Feed
- Keine Random- oder Discovery-Logik
- Sortierung: `createdAt DESC`
- Inkludierte Relationen:
- `pet`
- `author`
Smoke-Tests erfolgreich (Invoke-RestMethod).
**Status:** ✅ abgeschlossen / frontend-ready
---
## 🩺 5. Betrieb & Stabilität
- `GET /health` Endpoint vorhanden
- Server startet ohne Errors
- Keine offenen TypeScript- oder DI-Probleme
- Lokales DEV-Setup reproduzierbar
**Status:** ✅ abgeschlossen
---
## 🧩 6. Bewusst ausgeklammerte Spezial-Features
Diese Themen sind **nicht Teil des Backend-Freeze** und werden separat umgesetzt:
- Discovery Feed (siehe Issue #243)
- Likes
- Kommentare
- Notifications
- Admin / Moderation
- Echte Auth (JWT, Login)
> Diese Features sind **entkoppelt geplant**,
> sodass sie nach Live-Frontend **ohne Rewrites** ergänzt werden können.
---
## 🧊 Freeze-Regel
Ab diesem Punkt gilt für das Backend:
- ❄️ Keine neuen Features
- ❄️ Nur Bugfixes
- ❄️ Keine API-Breaking-Changes
- ❄️ Fokus liegt auf Frontend-Entwicklung
---
## ✅ Fazit
Das Backend befindet sich im **frontend-ready Freeze-Zustand**.
- Die API ist stabil
- Die Architektur ist vorbereitet
- Erweiterungen sind planbar, aber nicht blockierend
➡️ **Empfohlener nächster Schritt:** Frontend entwickeln.
+26
View File
@@ -0,0 +1,26 @@
## Lokales Dev-Setup
Frontend:
- URL: http://localhost:3000
Backend:
- URL: http://localhost:3001
ENV:
- NEXT_PUBLIC_API_URL=http://localhost:3001
Hinweis:
Backend ist frontend-ready eingefroren.
Neue Features nur über separate Issues.
# 🛠 DEV SETUP – OnlyPets
## Ports
- Frontend: http://localhost:3000
- Backend: http://localhost:3001
## Frontend (Next.js)
```bash
cd frontend
npm install
npm run dev
+542
View File
@@ -0,0 +1,542 @@
PROJECT: OnlyPets (ehemals OnlyCats)
STACK:
- Backend: NestJS + Prisma
- DB: PostgreSQL (Docker, DEV)
- Frontend: Next.js (separat, noch nicht Thema dieses Blocks)
CORE IDEA:
- Tiere stehen IMMER im Mittelpunkt
- Kein Post ohne Pet
- User sind sekundär, Pets sind primäre Entität
DATA PRINCIPLES:
- Single Source of Truth
- Pet.age wird NIE gespeichert
- Stattdessen: Pet.birthDate (DateTime)
- Alter & Geburtstag werden IMMER berechnet (Utils / Frontend)
PET RULES:
- birthDate optional, aber bevorzugt
- fallbackAge nur temporär (kein Persist)
- Pet Edit erlaubt birthDate-Korrektur
- type & breed sind immutable
- visibility: public | followers (serverseitig enforced)
BACKEND STATUS:
- Prisma Schema vorhanden
- Migration erfolgreich ausgeführt
- Tabellen existieren
- Follow-System implementiert
- Visibility-Logik vorbereitet
- Notifications vorbereitet (FOLLOW, BIRTHDAY – später)
DATABASE (DEV):
- PostgreSQL läuft im Docker
- Eigener Port: 55432
- Credentials:
user: onlypets_user
pass: onlypets_pass
db: onlypets
- DATABASE_URL:
postgresql://onlypets_user:onlypets_pass@localhost:55432/onlypets
SEED STATUS:
- seed.ts vollständig funktionsfähig
- Seed OHNE Passwort (Auth kommt später)
- User werden per upsert erstellt (idempotent)
- Enthält:
- 3 Alpha-User
- Pets mit birthDate (DateTime via new Date())
- Posts
- Follow-Beziehungen
- Seed läuft nur NACH Migration
- Seed mehrfach ausführbar ohne Fehler
AUTH STATUS:
- Noch kein Passwortfeld im User-Model
- Auth bewusst verschoben
- Schema & Seed auth-ready gedacht (passwordHash später)
ISSUES:
- Issue #1: Seed / DB → erledigt
- Issue #242: Teilweise vorbereitet (Seed & Datenbasis), noch offen
LAST ACTIONS:
- Docker-Port-Konflikte gelöst
- ENV-Probleme identifiziert & behoben
- Migration → Seed Reihenfolge geklärt
- Seed finalisiert & stabil gemacht
NEXT INTENDED STEPS:
- Frontend anbinden & Feed testen
- Oder Auth vorbereiten (passwordHash + Migration)
- Oder Seed erweitern (Likes, Comments, Discovery)
-----------------------------------------------------
Gitea - Issue Liste zum abarbeiten !
Projekt: OnlyPets (Social Network für Haustiere)
Tech-Stack:
- Backend: NestJS + Prisma
- DB: PostgreSQL (lokal, aktuell instabil / Multi-Versionen 12,16,17)
- Frontend: Next.js (läuft getrennt unter C:/Projekte/OnlyPets/)
- Repo: Gitea (selfhosted), Issues aktiv genutzt
- OS: Windows (PowerShell)
Status Backend (wichtig):
- Prisma Schema vorhanden (User, Pet, Post, Follow etc.)
- birthDate wird als DateTime gespeichert (nicht Alter)
- Follow/Unfollow Controller existiert
- Feed + Discovery Feed Logik umgesetzt (Visibility public / followers)
- Fake-Auth / Dev-User Konzept beschlossen (kein echter Login yet)
- Seed läuft prinzipiell, aber:
- PostgreSQL Auth & Rollen waren fehlerhaft
- Mehrere PG-Versionen gleichzeitig aktiv
- Tabellen fehlten → Migration nötig
- Seed jetzt OHNE Passwort (Auth nur vorbereitet)
- Seed-Fehler zuletzt: Unique constraint (email) → Seed muss idempotent werden
Prisma / DB Probleme:
- DATABASE_URL mehrfach getestet (5432 / 55432)
- Auth-Fehler → User/Rolle existierte nicht
- Lösungspfad:
- Entweder PostgreSQL sauber neu (1 Version)
- ODER Übergang auf Docker-Postgres
- Migration (`prisma migrate deploy`) vor Seed ist Pflicht
Seed-Ziel:
- Dev-User (ohne Passwort)
- Pets mit birthDate als Date (ISO / new Date())
- Posts, Follows
- Kein Auth-Zwang
Auth-Strategie (entscheidend):
- Guard-first, KEINE Auth-Logik in Services
- Globaler Dummy Auth Guard
- @Public() Decorator für offene Endpunkte
- req.user immer definiert (null oder Fake-User)
- Rollen vorbereitet (USER / ADMIN / MOD)
Aktive Issues (Auszug, merken!):
- #242 Fake-User für Dev/Admin
- #241 Zentrale Auth-Regeln
- #240 Public Decorator
- #239 Globaler Auth-Guard (Dummy)
- #238 Request-User Typisierung
- Phase 1 – Fundament ist aktuell aktiv
- Phase 2 – Tier & Social danach
Arbeitsstil:
- Klare Pfade nennen (vollständig)
- Kurz & gebündelt, kein Datei-Hopping
- Backend zuerst stabil, Frontend später anbinden
---
# ✅ PROJEKT-BACKUP · STAND **26.12.2025 (TAGESABSCHLUSS)**
**Projekt:** OnlyPets (ehemals OnlyCats)
---
## STACK
* **Backend:** NestJS + Prisma
* **DB (DEV):** PostgreSQL (Docker)
* **Frontend:** Next.js (separat, nicht Teil dieses Blocks)
---
## CORE IDEA
* Tiere sind **primäre Entität**
* Kein Post ohne Pet
* User sind sekundär
---
## DATA PRINCIPLES
* Single Source of Truth
* `Pet.age` wird **niemals gespeichert**
* Stattdessen: `Pet.birthDate: DateTime`
* Alter & Geburtstag werden **immer berechnet**
* Keine redundanten Altersfelder
---
## PET RULES
* `birthDate` optional, aber bevorzugt
* `fallbackAge` nur temporär (nicht persistent)
* Pet-Edit erlaubt birthDate-Korrektur
* `type` & `breed` immutable
* `visibility`: `public | followers` (serverseitig enforced)
---
## DATABASE (DEV – AKTUELL STABIL)
* PostgreSQL läuft **im Docker**
* Port: **55432**
* Credentials:
* user: `onlypets_user`
* pass: `onlypets_pass`
* db: `onlypets`
* `DATABASE_URL`:
```
postgresql://onlypets_user:onlypets_pass@localhost:55432/onlypets
```
---
## SEED STATUS
* `seed.ts` vollständig funktionsfähig
* Seed **ohne Passwort** (Auth kommt später)
* User via **upsert** (idempotent)
* Enthält:
* Alpha-User
* Pets mit `birthDate`
* Posts
* Follow-Beziehungen
* Reihenfolge fix:
* Migration → Seed
* Seed mehrfach ausführbar **ohne Fehler**
---
## AUTH STATUS – **PHASE 1 ABGESCHLOSSEN (HEUTE)**
* ❌ Noch kein Passwortfeld im User-Model
* ❌ Kein echter Login (bewusst verschoben)
* ✅ Guard-first Architektur umgesetzt
* ✅ Globaler DummyAuthGuard aktiv
* ✅ `@Public()` Decorator implementiert
* ✅ `req.user` ist **immer definiert** (`null` oder Fake-User)
* ✅ Rollen vorbereitet (`USER | MOD | ADMIN`)
* ✅ Fake-User steuerbar über ENV
* ✅ Keine Auth-Logik in Services
---
## BACKEND STATUS – **HEUTE ERREICHT**
* NestJS **korrekt gebootstrapped**
* HTTP-Driver (`@nestjs/platform-express`) installiert
* TypeScript **strict & stabil**
* Decorators korrekt konfiguriert:
* `experimentalDecorators`
* `emitDecoratorMetadata`
* Globaler Auth-Guard registriert
* **HealthController aktiv**
* `GET /health`
* Antwort: `{ status: "ok", timestamp }`
* **Smoke Test erfolgreich**
* Server antwortet
* Routing funktioniert
* Auth blockiert Public-Routen nicht
---
## ISSUE STATUS (GITEA)
**HEUTE ERLEDIGT & GESCHLOSSEN:**
* #238 Request-User Typisierung
* #239 Globaler Auth-Guard (Dummy)
* #240 Public Decorator
* #241 Zentrale Auth-Regeln
* #242 Fake-User für Dev/Admin
➡️ **Phase 1 – Fundament offiziell abgeschlossen**
---
## LAST ACTIONS (HEUTE)
* NestJS Backend startfähig gemacht
* ts-node / TS / Decorator-Fehler sauber gelöst
* DummyAuth produktiv aktiviert
* Health-Endpoint als Smoke Test eingebaut
* Vollständiger **Backup-Commit** erstellt
* Push zu Gitea durchgeführt
---
## AKTUELLER PROJEKTSTATUS
🟢 **Backend Fundament stabil**
🟢 **Auth-Architektur vorbereitet**
🟢 **DB & Seed zuverlässig**
🟢 **Bereit für Phase 2**
---
## NEXT INTENDED STEPS (NICHT HEUTE)
* Phase 2:
* Pets / Posts Controller aktivieren
* Feed & Discovery real testen
* Oder:
* Auth Phase 2 (passwordHash + JWT)
* Oder:
* Frontend anbinden
---
## FAZIT (intern & ehrlich)
> Das heutige Ziel war **nicht Features**,
> sondern **Stabilität, Architektur und Reproduzierbarkeit**.
>
> **Ziel erreicht.**
---
Wenn du willst, mache ich dir das beim nächsten Start:
* als **CHANGELOG.md**
* oder als **`PROJECT_STATUS.md`**
* oder direkt als **README-Abschnitt**
---
Mega gut gepflegtes Tagebuch 👍
Ich habe deinen Text **inhaltlich geprüft, konsolidiert und auf den HEUTIGEN Stand erweitert**
– ohne deinen Stil kaputtzumachen.
Unten bekommst du **eine neue, saubere TXT-Version**, **1:1 Copy-Paste**, die du:
* als neues Tagebuch ablegen kannst **ODER**
* als Ersatz für die bisherige Datei nutzen kannst
Ich habe **nur ergänzt & präzisiert**, nichts „schöngefärbt“.
---
# 📘 PROJECT ONLYPETS – ENTWICKLERTAGEBUCH
**(ehemals OnlyCats)**
**Stand:** **27.12.2025 – FEED & FRONTEND STABILISIERT**
---
## STACK
* **Backend:** NestJS + Prisma
* **DB (DEV):** PostgreSQL (Docker)
* **Frontend:** Next.js (App Router, getrenntes Projekt)
* **Repo:** Gitea (selfhosted, Issues aktiv)
* **OS:** Windows (PowerShell)
---
## CORE IDEA
* Tiere sind **primäre Entität**
* Kein Post ohne Pet
* User sind sekundär (Owner / Kontext)
---
## DATA PRINCIPLES
* Single Source of Truth
* `Pet.age` wird **niemals gespeichert**
* Stattdessen: `Pet.birthDate: DateTime`
* Alter & Geburtstag werden **immer berechnet**
* Keine redundanten Altersfelder
---
## PET RULES
* `birthDate` optional, aber bevorzugt
* `fallbackAge` nur temporär (nicht persistent)
* Pet-Edit erlaubt birthDate-Korrektur
* `type` & `breed` immutable
* `visibility`: `public | followers` (serverseitig enforced)
---
## DATABASE (DEV – STABIL)
* PostgreSQL läuft **im Docker**
* Port: **55432**
* Credentials:
* user: `onlypets_user`
* pass: `onlypets_pass`
* db: `onlypets`
* `DATABASE_URL`:
```
postgresql://onlypets_user:onlypets_pass@localhost:55432/onlypets
```
---
## BACKEND STATUS (AKTUELL)
* Prisma Schema vollständig vorhanden
* Migration erfolgreich ausgeführt
* Tabellen existieren
* Follow / Unfollow implementiert
* Visibility-Logik (`public | followers`) vorbereitet
* Notifications vorbereitet (FOLLOW, BIRTHDAY – später)
* Feed-Endpoint `/feed` funktionsfähig
* Backend läuft stabil auf **Port 3001**
---
## SEED STATUS
* `seed.ts` vollständig funktionsfähig
* Seed **ohne Passwort** (Auth kommt später)
* User via **upsert** (idempotent)
* Enthält:
* Alpha-User
* Pets mit `birthDate`
* Posts
* Follow-Beziehungen
* Reihenfolge:
* Migration → Seed
* Seed mehrfach ausführbar **ohne Fehler**
---
## AUTH STATUS – PHASE 1 ABGESCHLOSSEN
* ❌ Kein Passwortfeld im User-Model
* ❌ Kein echter Login (bewusst verschoben)
* ✅ Guard-first Architektur
* ✅ Globaler `DummyAuthGuard`
* ✅ `@Public()` Decorator
* ✅ `req.user` immer definiert (Fake-User oder `null`)
* ✅ Rollen vorbereitet (`USER | MOD | ADMIN`)
* ✅ Fake-User steuerbar per ENV
* ✅ Keine Auth-Logik in Services
---
## FRONTEND STATUS – **HEUTE ERREICHT**
* Frontend läuft stabil auf **Port 3000**
* API-Anbindung an Backend (`3001`) erfolgreich getestet
* `/feed` Route existiert & funktioniert
* App Router korrekt genutzt
* **Mock-Feed aktiv**, kein Backend-Zwang
### Feed-Logik (Frontend)
* Follow-Feed + Discovery-Feed kombiniert
* Discovery-Ratio: **15 %**
* Relevance-Score:
* Pet-Typ
* Rasse
* Altersnähe
* Likes & Comments
* `isDiscovery` sauber an `PostCard` durchgereicht
### PostCard Status
* Typen konsolidiert (`petBirthDate?` ergänzt)
* Birthday-Logik vorbereitet
* Relative Zeit via `timeAgo`
* UX-Polish:
* klare Hierarchie (Titel / Meta / Text)
* Action-Zone (Like / Comment)
* Preview-Modal mit Touch-Dismiss
➡️ **Mock → Backend-Feed später nahezu 1:1 möglich**
---
## ISSUE STATUS (GITEA)
### Heute neu abgeschlossen:
* **#244 – Frontend: Feed-Route stabilisieren & PostCard UX-Polish** ✅
### Bereits erledigt (Phase 1 – Fundament):
* #238 Request-User Typisierung
* #239 Globaler Auth-Guard (Dummy)
* #240 Public Decorator
* #241 Zentrale Auth-Regeln
* #242 Fake-User für Dev/Admin
➡️ **Phase 1 offiziell abgeschlossen**
➡️ **Frontend-Feed vorbereitet**
---
## LAST ACTIONS (HEUTE)
* Backend-Ports bereinigt (3001)
* Frontend / Backend parallel stabilisiert
* Feed-Route verifiziert
* Mock-Feed & Discovery-Logik geprüft
* Typ-Inkonsistenzen behoben
* UX-Polish umgesetzt
* Commit & Push durchgeführt
* Issue #244 dokumentiert & geschlossen
---
## AKTUELLER PROJEKTSTATUS
🟢 Backend Fundament stabil
🟢 Auth-Architektur vorbereitet
🟢 DB & Seed zuverlässig
🟢 Frontend-Feed stabil (Mock)
🟢 Klarer Übergang zu Phase 2
---
## NEXT INTENDED STEPS (NACH BREAK)
* Phase 2:
* Mock-Feed → echtes `/feed` Backend
* Pets / Posts Controller vertiefen
* Oder:
* Auth Phase 2 (passwordHash + JWT)
* Oder:
* Frontend weiter UX-polishen
---
## FAZIT (INTERN)
> Heute ging es **nicht um neue Features**,
> sondern um **Stabilität, Konsistenz und Zukunftssicherheit**.
>
> **Ziel erreicht. Sehr guter Stand für eine Pause.**
+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();
});
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { PostsModule } from './posts/posts.module';
import { FeedModule } from './feed/feed.module';
import { PrismaModule } from './prisma/prisma.module';
import { PetsModule } from './pets/pets.module';
import { DummyAuthGuard } from './auth/guards/dummy-auth.guard';
import { HealthController } from './health.controller';
@Module({
imports: [
PrismaModule, // Basis zuerst
PostsModule, // /posts
FeedModule, // /feed (1:1 Spiegel)
PetsModule, // /pets
],
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;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Request } from 'express';
import { RequestUser } from '../../types/request-user.type';
export interface AuthRequest extends Request {
user: RequestUser;
}
@@ -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[];
}
+13
View File
@@ -0,0 +1,13 @@
import { Controller, Get, Req } from '@nestjs/common';
import { FeedService } from './feed.service';
import { AuthRequest } from '../auth/types/auth-request';
@Controller('feed')
export class FeedController {
constructor(private readonly feedService: FeedService) {}
@Get()
async getFeed(@Req() req: AuthRequest) {
return this.feedService.getFeed(req.user);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FeedController } from './feed.controller';
import { FeedService } from './feed.service';
import { PrismaService } from '../prisma/prisma.service';
@Module({
controllers: [FeedController],
providers: [FeedService, PrismaService],
})
export class FeedModule {}
+60
View File
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RequestUser } from '../types/request-user.type';
@Injectable()
export class FeedService {
constructor(private readonly prisma: PrismaService) {}
async getFeed(user: RequestUser | null) {
const userId = user?.id;
const posts = await this.prisma.post.findMany({
where: {
OR: [
{
pet: {
visibility: 'public',
},
},
...(userId
? [
{
pet: {
ownerId: userId,
},
},
{
pet: {
visibility: 'followers',
owner: {
followers: {
some: {
followerId: userId,
},
},
},
},
},
]
: []),
],
},
include: {
pet: true,
author: true,
},
orderBy: {
createdAt: 'desc',
},
});
return posts.map((post) => ({
...post,
isDiscovery: false,
}));
}
}
+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(),
};
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: 'http://localhost:3000',
credentials: true,
});
const port = process.env.PORT || 3001;
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';
}
+40
View File
@@ -0,0 +1,40 @@
import {
Controller,
Get,
Patch,
Param,
Body,
Req,
} from '@nestjs/common';
import { PetsService } from './pets.service';
import { UpdatePetDto } from './dto/update-pet.dto';
import { AuthRequest } from '../auth/types/auth-request';
@Controller('pets')
export class PetsController {
constructor(private readonly petsService: PetsService) {}
// ✅ READ-ONLY: Pet-Profil anzeigen
@Get(':id')
getPet(
@Param('id') id: string,
@Req() req: AuthRequest,
): Promise<{ owner: { id: number; name: string; createdAt: Date; email: string; }; } & { id: number; name: string; type: string; breed: string; birthDate: Date | null; gender: string | null; bio: string | null; avatarUrl: string | null; visibility: string; ownerId: number; }> {
return this.petsService.getPetById(Number(id), req.user);
}
@Patch(':id')
updatePet(
@Param('id') id: string,
@Body() dto: UpdatePetDto,
@Req() req: AuthRequest,
) {
return this.petsService.updatePet(
Number(id),
req.user!.id,
dto,
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PetsService } from './pets.service';
import { PetsController } from './pets.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [PetsController],
providers: [PetsService],
})
export class PetsModule {}
+168
View File
@@ -0,0 +1,168 @@
import {
Injectable,
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto';
import { RequestUser } from '../types/request-user.type';
@Injectable()
export class PetsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Read-only: einzelnes Pet für Profilansicht
* Visibility-Regeln:
* - public: immer sichtbar
* - owner: immer sichtbar
* - followers: nur für Follower
*/
async getPetById(id: number, user: RequestUser) {
const pet = await this.prisma.pet.findUnique({
where: { id },
include: {
owner: true,
},
});
if (!pet) {
throw new NotFoundException('Pet not found');
}
// 1️⃣ Public Pets → immer sichtbar
if (pet.visibility === 'public') {
return pet;
}
// 2️⃣ Owner darf sein Pet immer sehen
if (user && pet.ownerId === user.id) {
return pet;
}
// 3️⃣ Followers-Visibility
if (user && pet.visibility === 'followers') {
const isFollower = await this.prisma.follow.findFirst({
where: {
followerId: user.id,
followingId: pet.ownerId,
},
});
if (isFollower) {
return pet;
}
}
// 4️⃣ Alles andere → verboten
throw new ForbiddenException('You are not allowed to view this pet');
}
// -------------------------------
// 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,
},
});
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, Req } from '@nestjs/common';
import { PostsService } from './posts.service';
import { AuthRequest } from '../auth/types/auth-request';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
async getPosts(@Req() req: AuthRequest) {
return this.postsService.getPosts(req.user);
}
@Get('my')
async getMyPosts(@Req() req: AuthRequest) {
return this.postsService.getMyPosts(req.user);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';
import { PrismaModule } from '../prisma/prisma.module';
import { PrismaService } from '../prisma/prisma.service';
@Module({
imports: [
PrismaModule, // 👈 DAS ist der Schlüssel
],
controllers: [PostsController],
providers: [PostsService, PrismaService,],
})
export class PostsModule {}
+70
View File
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RequestUser } from '../types/request-user.type';
@Injectable()
export class PostsService {
constructor(private readonly prisma: PrismaService) {}
async getPosts(user: RequestUser) {
const userId = user?.id;
return this.prisma.post.findMany({
where: {
OR: [
{
pet: {
visibility: 'public',
},
},
...(userId
? [
{
pet: {
ownerId: userId,
},
},
{
pet: {
visibility: 'followers',
owner: {
followers: {
some: {
followerId: userId,
},
},
},
},
},
]
: []),
],
},
include: {
pet: true,
author: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
async getMyPosts(user: RequestUser) {
return this.prisma.post.findMany({
where: {
authorId: user!.id, // 👈 explizit casten
},
include: {
pet: true,
author: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+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();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { RequestUser } from '../auth/types/request-user.type';
declare global {
namespace Express {
interface Request {
user: RequestUser;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
export type UserRole = 'USER' | 'ADMIN' | 'MOD';
export type RequestUser = {
id: number;
role: UserRole;
isFake?: boolean;
} | null;
+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:
+22
View File
@@ -34,3 +34,25 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Auth-Architektur (Grundlagen)
### Prinzipien
- Guard-first Architektur
- Keine Auth-Logik in Services
- Controller bleiben dünn
- Zentrale User-Wahrheit über Request-Kontext
### RequestUser
- `req.user` ist der einzige User-Kontext im Request
- Typ: `RequestUser`
- ID ist **number** (DB-kompatibel)
- Kann `null` sein (Public Routes)
```ts
export type UserRole = 'USER' | 'ADMIN' | 'MOD';
export type RequestUser = {
id: number;
role: UserRole;
isFake?: boolean;
} | null;
+113 -213
View File
@@ -1,242 +1,142 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useRef } from 'react';
import CommentModal from '../CommentModal/CommentModal'; import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
import { timeAgo } from "@/lib/time";
type Comment = { export type PostCardData = {
id: number; id: number;
author: string; petName: string;
petType: 'Hund' | 'Katze';
authorName: string;
createdAt: string;
text: string; text: string;
}; likesCount: number;
commentsCount: number;
type Media = { petbirthDate?: string;
type: 'image' | 'video';
url: string;
aspect: '1:1' | '9:16';
}; };
type PostCardProps = { type PostCardProps = {
petName: string; post: PostCardData;
petType: 'Hund' | 'Katze'; isDiscovery?: boolean
ownerName?: string; onOpenPreview: (post: PostCardData) => void;
text: string; onCommentClick?: (post: PostCardData) => void;
createdAt: string;
media?: Media;
likes: number;
likedByCurrentUser?: boolean;
commentsCount: number;
commentsList: Comment[];
}; };
// 🔐 Simulierter eingeloggter User
const CURRENT_USER_ID = 1;
export default function PostCard({ export default function PostCard({
petName, post,
petType, onOpenPreview,
ownerName, onCommentClick,
text, isDiscovery,
createdAt,
media,
likes,
likedByCurrentUser,
commentsCount,
commentsList,
}: PostCardProps) { }: PostCardProps) {
const [showComments, setShowComments] = useState(false); const likeRef = useRef<HTMLButtonElement | null>(null);
const [showMediaFullscreen, setShowMediaFullscreen] = useState(false); const commentRef = useRef<HTMLButtonElement | null>(null);
const isBirthday = post.petBirthDate && isBirthdayToday(post.petBirthDate);
const [localLikes, setLocalLikes] = useState(likes); function animate(
const [liked, setLiked] = useState(!!likedByCurrentUser); ref: React.RefObject<HTMLButtonElement>,
animation: string
const isSquare = media?.aspect === '1:1'; ) {
if (!ref.current) return;
// ⛔ Scroll sperren bei Vollbild const el = ref.current;
useEffect(() => { el.style.animation = animation;
if (showMediaFullscreen) { el.addEventListener(
document.body.style.overflow = 'hidden'; 'animationend',
} else { () => {
document.body.style.overflow = ''; el.style.animation = '';
} },
}, [showMediaFullscreen]); { once: true }
);
}
return ( return (
<> <article
<article className={`postcard
style={{ ${isDiscovery ? 'postcard--discovery' : ''}
border: '1px solid #e5e5e5', ${isBirthday ? 'postcard--birthday' : ''}
borderRadius: '16px', `}
backgroundColor: 'white', >
overflow: 'hidden',
}}
{/* Klickbarer Bereich → Preview */}
<div
className="postcard-main"
onClick={() => onOpenPreview(post)}
> >
{/* HEADER */} <div className="postcard-media" />
<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 style={{ fontSize: '0.8rem', color: '#777' }}> <div className="postcard-content">
{createdAt} <div className="postcard-header">
{ownerName && ( {isDiscovery && (
<> <div className="post-discovery-wrapper">
{' · '}
<span style={{ fontStyle: 'italic' }}> </div>
verwaltet von {ownerName} )}
</span>
</>
)}
</div>
</header>
{/* MEDIA */} <div className="postcard-title-row">
{media && ( <strong className="postcard-title">
<div {post.petName}
onClick={() => setShowMediaFullscreen(true)} </strong>
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',
}}
/>
)}
{media.type === 'video' && ( <span className="postcard-meta">
<video · {post.petType}
src={media.url} {post.petBirthDate && ` · ${formatPetAge(post.petBirthDate)}`}
muted · {post.authorName}
style={{ · {timeAgo(post.createdAt)}
width: '100%', </span>
height: '100%',
objectFit: 'cover',
}}
/>
)}
</div>
)}
{/* TEXT */} {isBirthday && (
<div style={{ padding: '1rem', fontSize: '0.95rem' }}> <span
{text} className="birthday-dot"
title="Heute ist Geburtstag 🎉"
aria-label="Geburtstag"
>
🎂
</span>
)}
</div>
</div>
<p className="postcard-text">{post.text}</p>
</div> </div>
</div>
{/* FOOTER */} {/* Interaktionen */}
<footer <div className="postcard-actions">
style={{ <button
padding: '0 1rem 1rem', ref={likeRef}
display: 'flex', type="button"
gap: '1.2rem', className="postcard-action"
fontSize: '0.85rem', onClick={(e) => {
e.stopPropagation();
animate(likeRef, 'heart-pop 0.3s ease');
// später: Like-Logik
}} }}
> >
<button <span aria-hidden>❤️</span>
type="button" <span>{post.likesCount}</span>
onClick={() => { </button>
setLiked(!liked);
setLocalLikes((prev) => (liked ? prev - 1 : prev + 1));
console.log('LIKE', { <button
byUser: CURRENT_USER_ID, ref={commentRef}
pet: petName, type="button"
}); className="postcard-action"
}} onClick={(e) => {
style={{ e.stopPropagation();
background: 'none', animate(commentRef, 'comment-wiggle 0.25s ease');
border: 'none', onCommentClick?.(post);
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',
}} }}
> >
<div <span aria-hidden>💬</span>
onClick={(e) => e.stopPropagation()} <span>{post.commentsCount}</span>
style={{ </button>
maxWidth: '95vw', </div>
maxHeight: '95vh', </article>
}}
>
{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>
)}
</>
); );
} }
+90 -335
View File
@@ -1,26 +1,16 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import PostCard, {
PostCardData,
} from '@/app/components/PostCard/PostCard';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
type ExplorePet = { /* =======================
id: number; MOCK DATA
name: string; ======================= */
type: 'Hund' | 'Katze';
};
type ExplorePost = { const trendingPets = [
id: number;
petId: number;
petName: string;
petType: 'Hund' | 'Katze';
authorName: string;
createdAt: string;
text: string;
likesCount: number;
commentsCount: number;
};
const trendingPets: ExplorePet[] = [
{ id: 1, name: 'Luna', type: 'Katze' }, { id: 1, name: 'Luna', type: 'Katze' },
{ id: 2, name: 'Bello', type: 'Hund' }, { id: 2, name: 'Bello', type: 'Hund' },
{ id: 3, name: 'Milo', type: 'Katze' }, { id: 3, name: 'Milo', type: 'Katze' },
@@ -29,10 +19,9 @@ const trendingPets: ExplorePet[] = [
{ id: 6, name: 'Leo', type: 'Hund' }, { id: 6, name: 'Leo', type: 'Hund' },
]; ];
const latestPosts: ExplorePost[] = [ const latestPosts: PostCardData[] = [
{ {
id: 101, id: 101,
petId: 1,
petName: 'Luna', petName: 'Luna',
petType: 'Katze', petType: 'Katze',
authorName: 'Anna', authorName: 'Anna',
@@ -43,7 +32,6 @@ const latestPosts: ExplorePost[] = [
}, },
{ {
id: 102, id: 102,
petId: 2,
petName: 'Bello', petName: 'Bello',
petType: 'Hund', petType: 'Hund',
authorName: 'Markus', authorName: 'Markus',
@@ -54,7 +42,6 @@ const latestPosts: ExplorePost[] = [
}, },
{ {
id: 103, id: 103,
petId: 3,
petName: 'Milo', petName: 'Milo',
petType: 'Katze', petType: 'Katze',
authorName: 'Sophie', authorName: 'Sophie',
@@ -65,7 +52,6 @@ const latestPosts: ExplorePost[] = [
}, },
{ {
id: 104, id: 104,
petId: 4,
petName: 'Rocky', petName: 'Rocky',
petType: 'Hund', petType: 'Hund',
authorName: 'Timo', authorName: 'Timo',
@@ -76,15 +62,20 @@ const latestPosts: ExplorePost[] = [
}, },
]; ];
/* =======================
PAGE
======================= */
export default function ExplorePage() { export default function ExplorePage() {
const [activeFilter, setActiveFilter] = useState< const [activeFilter, setActiveFilter] =
'ALLE' | 'HUND' | 'KATZE' useState<'ALLE' | 'HUND' | 'KATZE'>('ALLE');
>('ALLE');
const [previewPost, setPreviewPost] = useState<ExplorePost | null>(null); const [previewPost, setPreviewPost] =
const [openComments, setOpenComments] = useState(false); 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 startY = useRef<number | null>(null);
const filteredPosts = latestPosts.filter((p) => { const filteredPosts = latestPosts.filter((p) => {
@@ -96,90 +87,46 @@ const [openComments, setOpenComments] = useState(false);
return ( return (
<> <>
{/* ================= HEADER / FILTER ================= */} {/* ================= HEADER / FILTER ================= */}
<section <section className="explore-header">
style={{
padding: '1rem',
display: 'grid',
gap: '1rem',
maxWidth: '720px',
margin: '0 auto',
}}
>
<header> <header>
<h1 style={{ marginBottom: '0.3rem' }}>Entdecken</h1> <h1>Entdecken</h1>
<p style={{ margin: 0, color: '#666' }}> <p>Neue Tiere, Trends & besondere Momente</p>
Neue Tiere, Trends & besondere Momente
</p>
</header> </header>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div className="explore-filter">
{[ {[
{ key: 'ALLE', label: 'Alle' }, { key: 'ALLE', label: 'Alle' },
{ key: 'HUND', label: 'Hunde' }, { key: 'HUND', label: 'Hunde' },
{ key: 'KATZE', label: 'Katzen' }, { key: 'KATZE', label: 'Katzen' },
].map((f) => { ].map((f) => (
const active = activeFilter === f.key; <button
return ( key={f.key}
<button className={
key={f.key} activeFilter === f.key
onClick={() => ? 'filter-button active'
setActiveFilter(f.key as 'ALLE' | 'HUND' | 'KATZE') : 'filter-button'
} }
style={{ onClick={() =>
padding: '0.45rem 0.9rem', setActiveFilter(f.key as 'ALLE' | 'HUND' | 'KATZE')
borderRadius: '999px', }
border: active ? 'none' : '1px solid #ccc', >
backgroundColor: active ? '#2E7D32' : 'white', {f.label}
color: active ? 'white' : 'inherit', </button>
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>
);
})}
</div> </div>
</section> </section>
{/* ================= BELIEBTE TIERE ================= */} {/* ================= BELIEBTE TIERE ================= */}
<section style={{ paddingLeft: '1rem', marginBottom: '1.5rem' }}> <section className="explore-trending">
<strong style={{ display: 'block', marginBottom: '0.6rem' }}> <strong>Beliebte Tiere</strong>
Beliebte Tiere
</strong>
<div <div className="trending-scroll hide-scrollbar">
className="hide-scrollbar"
style={{
display: 'flex',
gap: '0.8rem',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
{trendingPets.map((pet) => ( {trendingPets.map((pet) => (
<div key={pet.id} style={{ width: '84px', flex: '0 0 auto' }}> <div key={pet.id} className="trending-item">
<div <div className="trending-avatar">🐾</div>
style={{ <div className="trending-meta">
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' }}>
<strong>{pet.name}</strong> <strong>{pet.name}</strong>
<div style={{ color: '#777' }}>{pet.type}</div> <span>{pet.type}</span>
</div> </div>
</div> </div>
))} ))}
@@ -187,151 +134,23 @@ const [openComments, setOpenComments] = useState(false);
</section> </section>
{/* ================= POSTS ================= */} {/* ================= POSTS ================= */}
<section <section className="explore-posts">
style={{ <strong>Neueste Beiträge</strong>
padding: '0 1rem 1rem',
maxWidth: '720px',
margin: '0 auto',
}}
>
<strong style={{ display: 'block', marginBottom: '0.6rem' }}>
Neueste Beiträge
</strong>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.9rem' }}> <div className="post-list">
{filteredPosts.map((post) => ( {filteredPosts.map((post) => (
<article <PostCard
key={post.id} key={post.id}
style={{ post={post}
border: '1px solid #eee', onOpenPreview={(p) => {
borderRadius: '18px', setOpenComments(false);
overflow: 'hidden', setPreviewPost(p);
backgroundColor: 'white',
}} }}
> onCommentClick={(p) => {
{/* ✅ Nur dieser Bereich öffnet das Modal */} setOpenComments(true);
<div setPreviewPost(p);
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>
))} ))}
</div> </div>
</section> </section>
@@ -339,127 +158,63 @@ const [openComments, setOpenComments] = useState(false);
{/* ================= PREVIEW MODAL ================= */} {/* ================= PREVIEW MODAL ================= */}
{previewPost && ( {previewPost && (
<div <div
onClick={() => setPreviewPost(null)} className="post-preview-overlay"
style={{ onClick={() => {
position: 'fixed', setPreviewPost(null);
inset: 0, setOpenComments(false);
backgroundColor: 'rgba(0,0,0,0.4)',
zIndex: 50,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-end',
}} }}
> >
<div <div
className="post-preview-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => (startY.current = e.touches[0].clientY)} onTouchStart={(e) =>
(startY.current = e.touches[0].clientY)
}
onTouchEnd={(e) => { onTouchEnd={(e) => {
if ( if (
startY.current && startY.current &&
e.changedTouches[0].clientY - startY.current > 80 e.changedTouches[0].clientY - startY.current > 80
) { ) {
setPreviewPost(null); 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 <div className="modal-handle" />
style={{
width: '36px',
height: '4px',
borderRadius: '999px',
backgroundColor: '#ccc',
margin: '0 auto 0.8rem',
}}
/>
<div <div className="modal-media" />
style={{
width: '100%',
aspectRatio: '3 / 2',
backgroundColor: '#eaeaea',
borderRadius: '14px',
marginBottom: '0.8rem',
}}
/>
<strong>{previewPost.petName}</strong>{' '} <strong>{previewPost.petName}</strong>{' '}
<span style={{ color: '#777', fontSize: '0.85rem' }}> <span className="modal-meta">
· {previewPost.petType} · {previewPost.authorName} · {previewPost.petType} · {previewPost.authorName}
</span> </span>
<p style={{ marginTop: '0.6rem' }}>{previewPost.text}</p> <p className="modal-text">{previewPost.text}</p>
{openComments && (
<div
style={{
marginTop: '1rem',
paddingTop: '0.8rem',
borderTop: '1px solid #eee',
}}
>
<strong style={{ fontSize: '0.9rem' }}>Kommentare</strong>
<div <div className="modal-stats">
style={{ <span>❤️ {previewPost.likesCount}</span>
marginTop: '0.6rem', <span>💬 {previewPost.commentsCount}</span>
fontSize: '0.85rem', </div>
color: '#666',
}}
>
Noch keine Kommentare.
</div>
<input {openComments && (
placeholder="Kommentar schreiben…" <div className="modal-comments">
style={{ <strong>Kommentare</strong>
marginTop: '0.6rem', <p className="modal-comment-empty">
width: '100%', Noch keine Kommentare.
padding: '0.6rem 0.8rem', </p>
borderRadius: '999px',
border: '1px solid #ccc',
fontSize: '0.85rem',
}}
autoFocus
/>
</div>
)}
<div <input
style={{ className="modal-comment-input"
display: 'flex', placeholder="Kommentar schreiben…"
justifyContent: 'space-between', autoFocus
alignItems: 'center', />
marginTop: '1rem',
}}
>
<div style={{ fontSize: '0.9rem', color: '#555', display: 'flex', gap: '1rem' }}>
<span>❤️ {previewPost.likesCount}</span>
<span>💬 {previewPost.commentsCount}</span>
</div> </div>
)}
<div className="modal-actions">
<a <a
href={`/post/${previewPost.id}`} href={`/post/${previewPost.id}`}
style={{ className="modal-open-post"
padding: '0.5rem 0.9rem',
borderRadius: '999px',
backgroundColor: '#2E7D32',
color: 'white',
textDecoration: 'none',
fontSize: '0.85rem',
fontWeight: 600,
}}
> >
Zum Beitrag Zum Beitrag
</a> </a>
+208 -149
View File
@@ -1,179 +1,238 @@
'use client'; 'use client';
import { useState } from 'react'; import { useRef, useState } from 'react';
import PostCard from '../components/PostCard/PostCard'; import PostCard, {
import FreshPostsSlider from '../components/FreshPostsSlider/FreshPostsSlider'; PostCardData,
} from '@/app/components/PostCard/PostCard';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
/* ---------------- TYPES ---------------- */ /* =======================
MOCK: USER KONTEXT
======================= */
type Comment = { const myPets = [
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[] = [
{ {
id: 1, id: 1,
name: 'Anna', petType: 'Katze',
avatar: 'https://i.pravatar.cc/100?img=12', breed: 'Bengal',
}, ageMonths: 24,
{
id: 2,
name: 'Markus',
avatar: 'https://i.pravatar.cc/100?img=32',
}, },
]; ];
/* ---------------- 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, petId: 1,
petName: 'Luna', petName: 'Luna',
petType: 'Katze', petType: 'Katze',
breed: 'Bengal',
ownerId: 1, ageMonths: 26,
ownerName: 'Anna', authorName: 'Anna',
createdAt: new Date().toISOString(),
text: 'Luna genießt die Sonne auf dem Balkon ☀️', text: 'Luna liebt den neuen Kratzbaum.',
createdAt: 'vor 2 Stunden', likesCount: 8,
media: {
type: 'image',
url: 'https://placekitten.com/600/600',
aspect: '1:1',
},
likes: 12,
commentsCount: 2, commentsCount: 2,
commentsList: [
{ id: 1, author: 'Lisa', text: 'Wie süß 😍' },
{ id: 2, author: 'Tom', text: 'Typisch Katze!' },
],
}, },
{ {
id: 2, id: 402,
petId: 2, petId: 2,
petName: 'Bello', petName: 'Rocky',
petType: 'Hund', petType: 'Hund',
breed: 'Labrador',
ownerId: 2, ageMonths: 30,
ownerName: 'Markus', authorName: 'Tim',
createdAt: new Date().toISOString(),
text: 'Spaziergang im Park – jemand hatte sehr viel Energie 🐕', text: 'Rocky war schwimmen 🐕‍🦺',
createdAt: 'heute Morgen', likesCount: 15,
commentsCount: 4,
media: { },
type: 'video', {
url: 'https://www.w3schools.com/html/mov_bbb.mp4', id: 403,
aspect: '9:16', petId: 3,
}, petName: 'Milo',
petType: 'Katze',
likes: 20, breed: 'Bengal',
ageMonths: 22,
authorName: 'Sophie',
commentsCount: 0, createdAt: new Date().toISOString(),
commentsList: [], 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: new Date().toISOString(),
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() { 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 const startY = useRef<number | null>(null);
? mockPosts.filter((post) => post.ownerId === selectedOwnerId)
: mockPosts; /* =======================
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 ( return (
<section <>
style={{ {/* ================= HEADER ================= */}
maxWidth: '600px', <section className="feed-header">
margin: '0 auto', <header>
display: 'grid', <h1>Feed</h1>
gap: '1.5rem', <p>Beiträge deiner Tiere & Abos</p>
}} </header>
>
{/* 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}
/>
))}
</section> </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>
)}
</>
); );
} }
+706 -14
View File
@@ -1,23 +1,51 @@
@import "tailwindcss"; @import "tailwindcss";
* {
box-sizing: border-box;
}
:root { :root {
--background: #ffffff; /* ===== COLORS ===== */
--foreground: #171717; --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 { body {
background: var(--background); background: var(--background);
@@ -96,4 +124,668 @@ body {
transform: translateY(0); 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);
}
/* ===========================
UX Polish – PostCard hierarchy & actions
(safe additions, no refactor)
=========================== */
.postcard-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--color-text-main, #111);
}
.postcard-meta {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.postcard-text {
margin-top: var(--space-sm, 10px);
font-size: 0.95rem;
line-height: 1.55;
color: var(--color-text-secondary, #1f2937);
max-width: 65ch;
}
/* Click affordance: main area opens preview, actions do not */
.postcard-main {
cursor: pointer;
}
.postcard-actions {
cursor: default;
margin-top: var(--space-sm, 10px);
padding-top: var(--space-sm, 10px);
border-top: 1px solid var(--color-border, rgba(0,0,0,0.08));
}
.post-locked-hint {
margin-top: 0.5rem;
font-size: 0.85rem;
opacity: 0.7;
}
+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'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import { useRouter } from 'next/navigation';
import { dogBreeds, catBreeds } from '@/app/data/breeds'; import {
formatPetAge,
type PetType = 'Hund' | 'Katze'; } from '@/app/lib/petAge';
/* Mock – später API */
type Pet = { type Pet = {
id: number; id: number;
name: string; name: string;
type: PetType; type: 'Hund' | 'Katze';
breed: string; breed: string;
birthDate?: string;
gender?: 'Männlich' | 'Weiblich' | 'Unbekannt';
bio?: string; bio?: string;
visibility: 'public' | 'followers';
}; };
// 🔧 Mock-Daten (später Backend) /* MOCK-DATEN */
const mockPets: Pet[] = [ const mockPets: Pet[] = [
{ {
id: 1, id: 1,
name: 'Luna', name: 'Luna',
type: 'Katze', type: 'Katze',
breed: 'Bengal x Mix', breed: 'Bengal',
bio: 'Neugierig, verspielt und liebt Sonne.', birthDate: '2021-05-14',
}, gender: 'Weiblich',
{ bio: 'Neugierig und verspielt',
id: 2, visibility: 'public',
name: 'Bello',
type: 'Hund',
breed: 'Labrador Retriever x Mix',
bio: 'Immer gute Laune.',
}, },
]; ];
type PageProps = { export default function EditPetPage({
params: Promise<{ id: string }>; params,
}; }: {
params: { id: string };
export default async function EditPetPage({ params }: PageProps) { }) {
const { id } = await params; const router = useRouter();
const petId = Number(id); const petId = Number(params.id);
const pet = mockPets.find((p) => p.id === petId); const pet = mockPets.find((p) => p.id === petId);
if (!pet) { const [name, setName] = useState('');
return <p>Haustier nicht gefunden.</p>; const [birthDate, setBirthDate] = useState('');
} const [ageFallback, setAgeFallback] = useState('');
const [gender, setGender] = useState<'Männlich' | 'Weiblich' | 'Unbekannt'>(
return <EditPetForm pet={pet} />; 'Unbekannt'
} );
const [bio, setBio] = useState('');
/* ---------- FORMULAR (Client Component) ---------- */ const [visibility, setVisibility] = useState<'public' | 'followers'>(
'public'
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())
); );
function handleSubmit(event: React.FormEvent) { const [error, setError] = useState('');
event.preventDefault(); 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(''); setError('');
setSuccess(''); setSuccess('');
if (!name.trim()) { if (!name.trim()) {
setError('Name darf nicht leer sein.'); setError('Der Name darf nicht leer sein.');
return; return;
} }
if (!breed) { const updatedPet = {
setError('Bitte wähle eine Rasse aus.'); ...pet,
return;
}
const updatedPetPayload = {
id: pet.id,
name, name,
type, birthDate: birthDate || null,
breed, ageFallback: birthDate ? null : ageFallback || null,
bio: bio || null, gender,
updatedAt: new Date().toISOString(), 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: function handleCancel() {
// await fetch(`/api/pets/${pet.id}`, { method: 'DELETE' }) router.back();
// router.push('/pets'); }
setShowDeleteConfirm(false);
alert('Haustier wurde technisch gelöscht (Mock)');
}
return ( return (
<section <section className="pets-page">
style={{ <header className="pets-header">
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<h1>Haustier bearbeiten</h1> <h1>Haustier bearbeiten</h1>
<p style={{ color: '#555' }}> <p>Du kannst die Angaben jederzeit korrigieren.</p>
Passe die Informationen deines Haustiers an.
</p>
</header> </header>
<form <form className="pet-form" onSubmit={handleSubmit}>
onSubmit={handleSubmit}
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'grid',
gap: '1rem',
backgroundColor: 'white',
}}
>
{/* Name */} {/* Name */}
<div style={{ display: 'grid', gap: '0.4rem' }}> <div className="form-group">
<strong>Name</strong> <label>
<input Name <span className="required">*</span>
value={name} </label>
onChange={(e) => setName(e.target.value)} <input value={name} onChange={(e) => setName(e.target.value)} />
style={{
padding: '0.7rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/>
</div> </div>
{/* Art */} {/* Art & Rasse (read-only) */}
<div style={{ display: 'grid', gap: '0.4rem' }}> <div className="form-group">
<strong>Tier-Art</strong> <label>Tier-Art</label>
<select <input value={pet.type} disabled />
value={type} <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) => { onChange={(e) => {
setType(e.target.value as PetType); setBirthDate(e.target.value);
setBreed(''); if (e.target.value) setAgeFallback('');
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',
}} }}
/> />
{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 <select
value={breed} value={gender}
onChange={(e) => setBreed(e.target.value)} onChange={(e) =>
size={Math.min(filteredBreeds.length, 6)} setGender(e.target.value as 'Männlich' | 'Weiblich' | 'Unbekannt')
style={{ }
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
> >
{filteredBreeds.map((b) => ( <option value="Unbekannt">Unbekannt</option>
<option key={b} value={b}> <option value="Männlich">Männlich</option>
{b} <option value="Weiblich">Weiblich</option>
</option>
))}
</select> </select>
</div> </div>
{/* Bio */} {/* Bio */}
<div style={{ display: 'grid', gap: '0.4rem' }}> <div className="form-group">
<strong>Kurzbeschreibung</strong> <label>Kurzbeschreibung</label>
<textarea <textarea
rows={3}
value={bio} value={bio}
onChange={(e) => setBio(e.target.value)} onChange={(e) => setBio(e.target.value)}
rows={3}
style={{
padding: '0.8rem',
borderRadius: '12px',
border: '1px solid #ccc',
}}
/> />
</div> </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 */} {/* Meldungen */}
{error && <div style={{ background: '#f8d7da', padding: '0.7rem' }}>{error}</div>} {error && <div className="form-error">{error}</div>}
{success && <div style={{ background: '#d1e7dd', padding: '0.7rem' }}>{success}</div>} {success && <div className="form-success">{success}</div>}
{/* Aktionen */} {/* Aktionen */}
<div style={{ display: 'flex', gap: '0.8rem' }}> <div className="form-actions">
<button <button type="submit" className="button-primary">
type="submit"
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
}}
>
Änderungen speichern Änderungen speichern
</button> </button>
<button
<Link href={`/pets/${pet.id}`}>Abbrechen</Link> type="button"
<hr style={{ margin: '1rem 0', borderColor: '#eee' }} /> className="button-danger"
onClick={handleCancel}
<button >
type="button" Abbrechen
onClick={() => setShowDeleteConfirm(true)} </button>
style={{
padding: '0.7rem 1.2rem',
borderRadius: '12px',
border: '1px solid #d32f2f',
backgroundColor: 'white',
color: '#d32f2f',
fontWeight: 700,
cursor: 'pointer',
}}
>
Haustier löschen
</button>
</div> </div>
</form> </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> </section>
); );
} }
+99 -64
View File
@@ -1,27 +1,30 @@
'use client';
import { useParams } from 'next/navigation';
import PostCard from '@/app/components/PostCard/PostCard'; 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 = { type Pet = {
id: number; id: number;
name: string; name: string;
type: 'Hund' | 'Katze'; type: 'Hund' | 'Katze';
breed: string; breed: string;
bio?: string; age?: string;
posts: PetPost[]; 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[] = [ const mockPets: Pet[] = [
@@ -29,76 +32,108 @@ const mockPets: Pet[] = [
id: 1, id: 1,
name: 'Luna', name: 'Luna',
type: 'Katze', type: 'Katze',
breed: 'Bengal x Mix', breed: 'Bengal',
bio: 'Neugierig, verspielt und liebt Sonne.', age: '2 Jahre',
posts: [ owner: 'Anna',
{ birthDate: '2021-06-15',
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!' },
],
},
],
}, },
{ {
id: 2, id: 2,
name: 'Bello', name: 'Bello',
type: 'Hund', type: 'Hund',
breed: 'Labrador Retriever x Mix', breed: 'Labrador',
bio: 'Immer gute Laune.', age: '4 Jahre',
posts: [], owner: 'Markus',
birthDate: '2020-03-22',
}, },
]; ];
type PageProps = { const mockPosts: Post[] = [
params: Promise<{ id: string }>; {
}; 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) { export default function PetDetailPage() {
const { id } = await params; // ✅ DAS ist der entscheidende Fix const params = useParams();
const petId = Number(id); const petId = Number(params.id);
console.log('PET ID:', petId);
const pet = mockPets.find((p) => p.id === petId); const pet = mockPets.find((p) => p.id === petId);
const posts = mockPosts.filter((p) => p.petId === petId);
if (!pet) { 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 ( return (
<section style={{ maxWidth: '600px', margin: '0 auto', display: 'grid', gap: '1.5rem' }}> <section className="pet-detail-page">
<header style={{ border: '1px solid #e5e5e5', borderRadius: '16px', padding: '1rem' }}> {/* Header */}
<h1>{pet.name}</h1> <header className="pet-detail-header">
<div>{pet.type} · {pet.breed}</div> <div className="pet-detail-avatar">🐾</div>
{pet.bio && <p>{pet.bio}</p>}
<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> </header>
<section style={{ display: 'grid', gap: '1.2rem' }}> {/* Posts */}
<section className="pet-detail-posts">
<h2>Beiträge</h2> <h2>Beiträge</h2>
{pet.posts.length === 0 && ( {posts.length === 0 ? (
<p style={{ color: '#777' }}>Dieses Haustier hat noch keine Beiträge.</p> <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>
</section> </section>
); );
+134 -125
View File
@@ -2,37 +2,43 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { dogBreeds, catBreeds } from '@/app/data/breeds'; import { dogBreeds, catBreeds } from '@/app/data/breeds';
type PetType = 'Hund' | 'Katze' | ''; type PetType = 'Hund' | 'Katze' | '';
export default function NewPetPage() { export default function NewPetPage() {
const router = useRouter();
// Pflichtfelder
const [name, setName] = useState(''); const [name, setName] = useState('');
const [type, setType] = useState<PetType>(''); const [type, setType] = useState<PetType>('');
const [breed, setBreed] = useState(''); 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 [bio, setBio] = useState('');
const [visibility, setVisibility] = useState<'public' | 'followers'>(
'public'
);
// UX States
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const breeds = const breeds = type === 'Hund' ? dogBreeds : catBreeds;
type === 'Hund'
? dogBreeds
: type === 'Katze'
? catBreeds
: [];
const filteredBreeds = breeds.filter((b) => function handleSubmit(e: React.FormEvent) {
b.toLowerCase().includes(breedSearch.toLowerCase()) e.preventDefault();
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError(''); setError('');
setSuccess(''); setSuccess('');
if (!name.trim()) { if (!name.trim()) {
setError('Bitte gib einen Namen für dein Haustier ein.'); setError('Bitte gib einen Namen für dein Tier an.');
return; return;
} }
@@ -42,120 +48,80 @@ export default function NewPetPage() {
} }
if (!breed) { if (!breed) {
setError('Bitte wähle eine Rasse aus.'); setError('Bitte wähle eine Rasse oder „Mix“ aus.');
return; return;
} }
const newPetPayload = { const petPayload = {
name, name,
type, type,
breed, breed,
bio: bio || null, birthDate: birthDate || null,
createdAt: new Date().toISOString(), ageFallback: birthDate ? null : ageFallback || null,
gender,
bio,
visibility,
}; };
console.log('Neues Haustier (Mock):', newPetPayload); console.log('NEW PET:', petPayload);
setSuccess('Haustier wurde technisch angelegt ✅'); setSuccess('Haustier wurde erfolgreich angelegt.');
setName(''); setTimeout(() => {
setType(''); router.push('/pets');
setBreed(''); }, 800);
setBreedSearch(''); }
setBio('');
function handleCancel() {
router.back();
} }
return ( return (
<section <section className="pets-page">
style={{ <header className="pets-header">
maxWidth: '600px', <h1>Haustier hinzufügen</h1>
margin: '0 auto', <p>Lege ein neues Tier für dein Profil an.</p>
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>
</header> </header>
<form <form className="pet-form" onSubmit={handleSubmit}>
onSubmit={handleSubmit}
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'grid',
gap: '1rem',
backgroundColor: 'white',
}}
>
{/* Name */} {/* Name */}
<div style={{ display: 'grid', gap: '0.4rem' }}> <div className="form-group">
<strong>Name</strong> <label>
Name <span className="required">*</span>
</label>
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="z. B. Luna" placeholder="z. B. Luna"
style={{
padding: '0.7rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/> />
</div> </div>
{/* Art */} {/* Tier-Art */}
<div style={{ display: 'grid', gap: '0.4rem' }}> <div className="form-group">
<strong>Tier-Art</strong> <label>
Tier-Art <span className="required">*</span>
</label>
<select <select
value={type} value={type}
onChange={(e) => { onChange={(e) => {
setType(e.target.value as PetType); setType(e.target.value as PetType);
setBreed(''); setBreed('');
setBreedSearch('');
}}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
maxWidth: '220px',
}} }}
> >
<option value="">Bitte auswählen</option> <option value="">Bitte auswählen</option>
<option value="Katze">Katze</option>
<option value="Hund">Hund</option> <option value="Hund">Hund</option>
<option value="Katze">Katze</option>
</select> </select>
</div> </div>
{/* Rasse */} {/* Rasse */}
{type && ( {type && (
<div style={{ display: 'grid', gap: '0.5rem' }}> <div className="form-group">
<strong>Rasse</strong> <label>
Rasse <span className="required">*</span>
<input </label>
placeholder="Rasse suchen …" <select value={breed} onChange={(e) => setBreed(e.target.value)}>
value={breedSearch} <option value="">Bitte auswählen</option>
onChange={(e) => setBreedSearch(e.target.value)} {breeds.map((b) => (
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) => (
<option key={b} value={b}> <option key={b} value={b}>
{b} {b}
</option> </option>
@@ -164,51 +130,94 @@ export default function NewPetPage() {
</div> </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 */} {/* Bio */}
<div style={{ display: 'grid', gap: '0.4rem' }}> <div className="form-group">
<strong>Kurzbeschreibung (optional)</strong> <label>Kurzbeschreibung</label>
<textarea <textarea
rows={3}
value={bio} value={bio}
onChange={(e) => setBio(e.target.value)} onChange={(e) => setBio(e.target.value)}
placeholder="Alter, Charakter, Besonderheiten …" placeholder="Charakter, Besonderheiten, Alter …"
rows={3}
style={{
padding: '0.8rem',
borderRadius: '12px',
border: '1px solid #ccc',
}}
/> />
</div> </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 */} {/* Meldungen */}
{error && ( {error && <div className="form-error">{error}</div>}
<div style={{ background: '#f8d7da', padding: '0.7rem' }}> {success && <div className="form-success">{success}</div>}
{error}
</div>
)}
{success && (
<div style={{ background: '#d1e7dd', padding: '0.7rem' }}>
{success}
</div>
)}
{/* Aktionen */} {/* Aktionen */}
<div style={{ display: 'flex', gap: '0.8rem' }}> <div className="form-actions">
<button <button type="submit" className="button-primary">
type="submit"
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
}}
>
Haustier speichern Haustier speichern
</button> </button>
<button
<Link href="/pets">Abbrechen</Link> type="button"
className="button-danger"
onClick={handleCancel}
>
Abbrechen
</button>
</div> </div>
</form> </form>
</section> </section>
+51 -46
View File
@@ -1,75 +1,80 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
type Pet = { type Pet = {
id: number; id: number;
name: string; name: string;
type: 'Hund' | 'Katze'; type: 'Hund' | 'Katze';
breed: string;
age?: string;
}; };
const mockPets: Pet[] = [ 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() { export default function PetsPage() {
return ( return (
<section <section className="pets-page">
style={{ {/* Header */}
maxWidth: '600px', <header className="pets-header">
margin: '0 auto', <h1>Meine Tiere</h1>
display: 'grid', <p>
gap: '1.5rem', Verwalte deine Haustiere – Beiträge sind immer einem Tier zugeordnet.
}}
>
<header>
<h1 style={{ marginBottom: '0.5rem' }}>Meine Haustiere</h1>
<p style={{ margin: 0, color: '#555' }}>
Verwalte deine Hunde und Katzen
</p> </p>
</header> </header>
<section style={{ display: 'grid', gap: '1rem' }}> {/* Pets Grid */}
<div className="pets-grid">
{mockPets.map((pet) => ( {mockPets.map((pet) => (
<Link <Link
key={pet.id} key={pet.id}
href={`/pets/${pet.id}`} href={`/pets/${pet.id}`}
style={{ className="pet-card"
textDecoration: 'none',
color: 'inherit',
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
> >
<div> <div className="pet-avatar">
<strong>{pet.name}</strong> 🐾
<div style={{ fontSize: '0.9rem', color: '#555' }}>
{pet.type}
</div>
</div> </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> </Link>
))} ))}
</section> </div>
<Link {/* Add Pet */}
href="/pets/new" <div className="pets-actions">
style={{ <Link href="/pets/new" className="button-primary">
alignSelf: 'flex-start', + Neues Tier hinzufügen
padding: '0.7rem 1rem', </Link>
borderRadius: '12px', </div>
backgroundColor: '#2E7D32',
color: 'white',
textDecoration: 'none',
fontWeight: 600,
}}
>
➕ Haustier hinzufügen
</Link>
</section> </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() { 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>
</>
);
} }
+63
View File
@@ -0,0 +1,63 @@
'use client';
import { useEffect } from 'react';
export default function TestPage() {
useEffect(() => {
console.log('--- FE TEST START ---');
// Feed testen
fetch('http://localhost:3001/feed')
.then(res => res.json())
.then(data => {
console.log('FEED RESPONSE:', data);
})
.catch(err => {
console.error('FEED ERROR:', err);
});
// Öffentliches Pet testen
fetch('http://localhost:3001/pets/2')
.then(async res => {
if (res.status === 403) {
console.log('PET 2: FORBIDDEN');
return;
}
const data = await res.json();
console.log('PET 2 RESPONSE:', data);
})
.catch(err => {
console.error('PET 2 ERROR:', err);
});
// Followers-Pet testen (soll 403 geben)
fetch('http://localhost:3001/pets/5')
.then(async res => {
if (res.status === 403) {
console.log('PET 5: FORBIDDEN (expected)');
return;
}
const data = await res.json();
console.log('PET 5 RESPONSE:', data);
})
.catch(err => {
console.error('PET 5 ERROR:', err);
});
}, []);
return (
<section style={{ padding: '2rem' }}>
<h1>🧪 Frontend API Test</h1>
<p>
Diese Seite dient nur zum Testen der Backend-APIs.
<br />
👉 Öffne die <strong>Browser-Konsole</strong>.
</p>
<ul>
<li>Feed wird geladen</li>
<li>Pet 2 (public) wird geladen</li>
<li>Pet 5 (followers) sollte 403 sein</li>
</ul>
</section>
);
}
+3 -1
View File
@@ -1,7 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ turbopack: {
root: __dirname,
},
}; };
export default nextConfig; export default nextConfig;
+1
View File
@@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"dev": "set NODE_OPTIONS=--max-old-space-size=4096 && next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
+20
View File
@@ -0,0 +1,20 @@
export function timeAgo(date: string | Date) {
const ts = new Date(date).getTime();
const diffMs = Date.now() - ts;
const minutes = Math.floor(diffMs / 60000);
if (minutes < 1) return "gerade eben";
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? "" : "n"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? "" : "n"}`;
const days = Math.floor(hours / 24);
if (days < 7) return `vor ${days} Tag${days === 1 ? "" : "en"}`;
const weeks = Math.floor(days / 7);
if (weeks < 4) return `vor ${weeks} Woche${weeks === 1 ? "" : "n"}`;
// Fallback: Datum kurz
return new Date(date).toLocaleDateString("de-DE");
}
+2737
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "onlypets-frontend",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"export": "next export"
},
"dependencies": {
"next": "^16.1.1",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"autoprefixer": "^10.4.23",
"eslint": "^8.40.0",
"next-auth": "^4.20.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
+24
View File
@@ -0,0 +1,24 @@
@echo off
title OnlyPets Dev Starter
echo ===============================
echo Starting OnlyPets (DEV)
echo ===============================
REM --- Backend ---
echo Starting Backend on port 3001...
start "OnlyPets Backend" cmd /k ^
cd /d C:\Projekte\OnlyPets\backend ^& ^
npm run dev
REM --- Frontend ---
echo Starting Frontend on port 3000...
start "OnlyPets Frontend" cmd /k ^
cd /d C:\Projekte\OnlyPets\frontend ^& ^
npm run dev
echo.
echo All services started.
echo Backend -> http://localhost:3001
echo Frontend -> http://localhost:3000
echo.
BIN
View File
Binary file not shown.