feat(post): improve media upload UX, drafts and safety actions

This commit is contained in:
admin committed 2025-12-24 11:59:27 +01:00
commit 6030143b7a
34 files changed
+8997

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
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.
@@ -0,0 +1,110 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
type AuthFormProps = {
title: string;
description: string;
submitLabel: string;
fields: {
label: string;
type: string;
placeholder: string;
}[];
footerText: string;
footerLinkText: string;
footerLinkHref: string;
};
export default function AuthForm({
title,
description,
submitLabel,
fields,
footerText,
footerLinkText,
footerLinkHref,
}: AuthFormProps) {
// ✅ Hooks IMMER hier drin
const [formData, setFormData] = useState<Record<string, string>>({});
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (Object.values(formData).some((value) => value.trim() === '')) {
alert('Bitte fülle alle Felder aus.');
return;
}
console.log('Formulardaten:', formData);
alert('Formular ist technisch bereit ✅ (Backend kommt später)');
}
return (
<section
style={{
maxWidth: '420px',
margin: '4rem auto',
padding: '2rem',
border: '1px solid #e5e5e5',
borderRadius: '16px',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<h1 style={{ marginBottom: '0.5rem' }}>{title}</h1>
<p style={{ margin: 0, color: '#555' }}>{description}</p>
</header>
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
{fields.map((field) => (
<label
key={field.label}
style={{ display: 'grid', gap: '0.3rem' }}
>
<span>{field.label}</span>
<input
type={field.type}
placeholder={field.placeholder}
value={formData[field.label] || ''}
onChange={(event) =>
setFormData({
...formData,
[field.label]: event.target.value,
})
}
style={{
padding: '0.6rem',
borderRadius: '8px',
border: '1px solid #ccc',
}}
/>
</label>
))}
<button
type="submit"
style={{
marginTop: '0.5rem',
padding: '0.7rem',
borderRadius: '10px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 600,
cursor: 'pointer',
}}
>
{submitLabel}
</button>
</form>
<footer style={{ textAlign: 'center', fontSize: '0.9rem' }}>
{footerText}{' '}
<Link href={footerLinkHref}>{footerLinkText}</Link>
</footer>
</section>
);
}
@@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
type Comment = {
id: number;
author: string;
text: string;
};
type Props = {
comments: Comment[];
onClose: () => void;
};
// 🔐 Simulierter eingeloggter User
const CURRENT_USER_NAME = 'Du';
// 😊 Kleine, sinnvolle Emoji-Auswahl (Start)
const EMOJIS = ['😍', '😂', '🥰', '🐶', '🐱', '❤️', '🔥', '👏', '🥹', '😎'];
export default function CommentModal({ comments, onClose }: Props) {
const [localComments, setLocalComments] = useState<Comment[]>(comments);
const [newComment, setNewComment] = useState('');
const [showEmojis, setShowEmojis] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!newComment.trim()) return;
const optimisticComment: Comment = {
id: Date.now(),
author: CURRENT_USER_NAME,
text: newComment.trim(),
};
setLocalComments((prev) => [...prev, optimisticComment]);
setNewComment('');
setShowEmojis(false);
console.log('NEW COMMENT (mock):', optimisticComment);
}
function addEmoji(emoji: string) {
setNewComment((prev) => prev + emoji);
}
return (
<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',
width: '100%',
maxWidth: '520px',
maxHeight: '80vh',
display: 'grid',
gridTemplateRows: 'auto 1fr auto',
}}
>
{/* HEADER */}
<header
style={{
padding: '1rem',
borderBottom: '1px solid #eee',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<strong>Kommentare</strong>
<button
onClick={onClose}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '1.2rem',
}}
>
✕
</button>
</header>
{/* LISTE */}
<section
style={{
padding: '1rem',
overflowY: 'auto',
display: 'grid',
gap: '0.8rem',
}}
>
{localComments.length === 0 && (
<p style={{ color: '#777' }}>Noch keine Kommentare.</p>
)}
{localComments.map((comment) => (
<div key={comment.id}>
<strong style={{ fontSize: '0.85rem' }}>
{comment.author}
</strong>
<div style={{ fontSize: '0.9rem' }}>{comment.text}</div>
</div>
))}
</section>
{/* EINGABE */}
<form
onSubmit={handleSubmit}
style={{
padding: '0.8rem',
borderTop: '1px solid #eee',
display: 'grid',
gap: '0.4rem',
}}
>
{/* Emoji Picker */}
{showEmojis && (
<div
style={{
display: 'flex',
gap: '0.4rem',
flexWrap: 'wrap',
padding: '0.4rem',
borderRadius: '12px',
backgroundColor: '#f5f5f5',
}}
>
{EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => addEmoji(emoji)}
style={{
fontSize: '1.3rem',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
>
{emoji}
</button>
))}
</div>
)}
{/* Input Row */}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
onClick={() => setShowEmojis((prev) => !prev)}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '1.3rem',
}}
title="Emoji hinzufügen"
>
😊
</button>
<input
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Kommentar schreiben …"
style={{
flex: 1,
padding: '0.6rem 0.8rem',
borderRadius: '12px',
border: '1px solid #ccc',
}}
/>
<button
type="submit"
style={{
padding: '0.6rem 1rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Senden
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
'use client';
type FreshUser = {
id: number;
name: string;
avatar: string;
};
type Props = {
users: FreshUser[];
onSelectUser: (userId: number | null) => void;
};
export default function FreshPostsSlider({ users, onSelectUser }: Props) {
return (
<div
style={{
display: 'flex',
gap: '0.8rem',
overflowX: 'auto',
paddingBottom: '0.5rem',
}}
>
{/* Alle */}
<button
onClick={() => onSelectUser(null)}
style={{
minWidth: '64px',
height: '64px',
borderRadius: '50%',
border: '2px solid #ccc',
background: '#f5f5f5',
cursor: 'pointer',
fontWeight: 600,
}}
>
Alle
</button>
{users.map((user) => (
<button
key={user.id}
onClick={() => onSelectUser(user.id)}
style={{
minWidth: '64px',
height: '64px',
borderRadius: '50%',
border: '2px solid #2E7D32',
backgroundImage: `url(${user.avatar})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
cursor: 'pointer',
}}
title={user.name}
/>
))}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export default function Navigation() {
const pathname = usePathname();
function isActive(path: string) {
return pathname === path ? 'active' : '';
}
return (
<header
style={{
padding: '1rem 2rem',
backgroundColor: '#2E7D32',
}}
>
<nav className="navbar">
<strong style={{ fontSize: '1.2rem', color: 'white' }}>
OnlyPets
</strong>
<Link href="/" className={isActive('/')}>Start</Link>
<Link href="/feed" className={isActive('/feed')}>Feed</Link>
<Link href="/explore" className={isActive('/explore')}>Entdecken</Link>
<Link href="/post/new" className={isActive('/post/new')}>Neuer Beitrag</Link>
<Link href="/profile" className={isActive('/profile')}>Profil</Link>
</nav>
</header>
);
}
@@ -0,0 +1,242 @@
'use client';
import { useState, useEffect } from 'react';
import CommentModal from '../CommentModal/CommentModal';
type Comment = {
id: number;
author: string;
text: string;
};
type Media = {
type: 'image' | 'video';
url: string;
aspect: '1:1' | '9:16';
};
type PostCardProps = {
petName: string;
petType: 'Hund' | 'Katze';
ownerName?: string;
text: string;
createdAt: string;
media?: Media;
likes: number;
likedByCurrentUser?: boolean;
commentsCount: number;
commentsList: Comment[];
};
// 🔐 Simulierter eingeloggter User
const CURRENT_USER_ID = 1;
export default function PostCard({
petName,
petType,
ownerName,
text,
createdAt,
media,
likes,
likedByCurrentUser,
commentsCount,
commentsList,
}: PostCardProps) {
const [showComments, setShowComments] = useState(false);
const [showMediaFullscreen, setShowMediaFullscreen] = useState(false);
const [localLikes, setLocalLikes] = useState(likes);
const [liked, setLiked] = useState(!!likedByCurrentUser);
const isSquare = media?.aspect === '1:1';
// ⛔ Scroll sperren bei Vollbild
useEffect(() => {
if (showMediaFullscreen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}, [showMediaFullscreen]);
return (
<>
<article
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
backgroundColor: 'white',
overflow: 'hidden',
}}
>
{/* HEADER */}
<header style={{ padding: '1rem', display: 'grid', gap: '0.2rem' }}>
<div style={{ fontWeight: 800, fontSize: '1.1rem' }}>
{petName}{' '}
<span style={{ fontWeight: 500, color: '#666' }}>
({petType})
</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#777' }}>
{createdAt}
{ownerName && (
<>
{' · '}
<span style={{ fontStyle: 'italic' }}>
verwaltet von {ownerName}
</span>
</>
)}
</div>
</header>
{/* MEDIA */}
{media && (
<div
onClick={() => setShowMediaFullscreen(true)}
style={{
width: '100%',
aspectRatio: isSquare ? '1 / 1' : '9 / 16',
backgroundColor: '#000',
cursor: 'pointer',
}}
>
{media.type === 'image' && (
<img
src={media.url}
alt={petName}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
{media.type === 'video' && (
<video
src={media.url}
muted
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
</div>
)}
{/* TEXT */}
<div style={{ padding: '1rem', fontSize: '0.95rem' }}>
{text}
</div>
{/* FOOTER */}
<footer
style={{
padding: '0 1rem 1rem',
display: 'flex',
gap: '1.2rem',
fontSize: '0.85rem',
}}
>
<button
type="button"
onClick={() => {
setLiked(!liked);
setLocalLikes((prev) => (liked ? prev - 1 : prev + 1));
console.log('LIKE', {
byUser: CURRENT_USER_ID,
pet: petName,
});
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontWeight: 600,
color: liked ? '#d32f2f' : '#555',
}}
>
❤️ {localLikes}
</button>
<button
type="button"
onClick={() => setShowComments(true)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontWeight: 600,
color: '#2E7D32',
}}
>
💬 {commentsCount} Kommentare
</button>
</footer>
</article>
{/* 💬 Kommentare */}
{showComments && (
<CommentModal
comments={commentsList}
onClose={() => setShowComments(false)}
/>
)}
{/* 🖼️ MEDIA FULLSCREEN */}
{showMediaFullscreen && media && (
<div
onClick={() => setShowMediaFullscreen(false)}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
cursor: 'zoom-out',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
}}
>
{media.type === 'image' && (
<img
src={media.url}
alt={petName}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
)}
{media.type === 'video' && (
<video
src={media.url}
controls
autoPlay
style={{
maxWidth: '100%',
maxHeight: '100%',
}}
/>
)}
</div>
</div>
)}
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
export const dogBreeds = [
'Mischling',
'Affenpinscher',
'Afghanischer Windhund',
'Akita',
'Alaskan Malamute',
'American Bulldog',
'American Pit Bull Terrier',
'Australian Shepherd',
'Australian Terrier',
'Basenji',
'Beagle',
'Belgischer Schäferhund',
'Bernhardiner',
'Bichon Frisé',
'Border Collie',
'Boston Terrier',
'Boxer',
'Bull Terrier',
'Bulldogge',
'Cane Corso',
'Cavalier King Charles Spaniel',
'Chihuahua',
'Chow-Chow',
'Cocker Spaniel',
'Dalmatiner',
'Dobermann',
'Dogo Argentino',
'English Setter',
'Französische Bulldogge',
'Golden Retriever',
'Großer Schweizer Sennenhund',
'Havaneser',
'Husky',
'Irish Setter',
'Jack Russell Terrier',
'Labrador Retriever',
'Leonberger',
'Malteser',
'Mops',
'Neufundländer',
'Pekinese',
'Pinscher',
'Pomeranian',
'Pudel',
'Rottweiler',
'Samojede',
'Schnauzer',
'Shar Pei',
'Shiba Inu',
'Shih Tzu',
'Siberian Husky',
'Staffordshire Bull Terrier',
'Weimaraner',
'West Highland White Terrier',
'Yorkshire Terrier',
].flatMap((breed) => [`${breed}`, `${breed} x Mix`]);
export const catBreeds = [
'Mischling',
'Abessinier',
'American Shorthair',
'Bengal',
'Birma',
'Bombay',
'Britisch Kurzhaar',
'Cornish Rex',
'Devon Rex',
'Europäisch Kurzhaar',
'Exotic Shorthair',
'Maine Coon',
'Manx',
'Norwegische Waldkatze',
'Orientalisch Kurzhaar',
'Perser',
'Ragdoll',
'Russisch Blau',
'Savannah',
'Scottish Fold',
'Selkirk Rex',
'Siam',
'Sibirische Katze',
'Singapura',
'Sphynx',
'Tonkanese',
'Türkisch Angora',
].flatMap((breed) => [`${breed}`, `${breed} x Mix`]);
+3
View File
@@ -0,0 +1,3 @@
export default function ExplorePage() {
return <h1>Entdecken</h1>;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+179
View File
@@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import PostCard from '../components/PostCard/PostCard';
import FreshPostsSlider from '../components/FreshPostsSlider/FreshPostsSlider';
/* ---------------- TYPES ---------------- */
type Comment = {
id: number;
author: string;
text: string;
};
type Media = {
type: 'image' | 'video';
url: string;
aspect: '1:1' | '9:16';
};
type FeedPost = {
id: number;
// 🐾 Tier (Absender)
petId: number;
petName: string;
petType: 'Hund' | 'Katze';
// 👤 Admin (sekundär)
ownerId: number;
ownerName: string;
// 📝 Inhalt
text: string;
createdAt: string;
// 📸 / 🎥 Media
media?: Media;
// ❤️ Interaktion
likes: number;
//likedByCurrentUser: number; // ✅ FEHLTE
commentsCount: number;
commentsList: Comment[];
};
type FeedAdmin = {
id: number;
name: string;
avatar: string;
};
/* ---------------- MOCK ADMINS ---------------- */
const mockAdmins: FeedAdmin[] = [
{
id: 1,
name: 'Anna',
avatar: 'https://i.pravatar.cc/100?img=12',
},
{
id: 2,
name: 'Markus',
avatar: 'https://i.pravatar.cc/100?img=32',
},
];
/* ---------------- MOCK POSTS ---------------- */
const mockPosts: FeedPost[] = [
{
id: 1,
petId: 1,
petName: 'Luna',
petType: 'Katze',
ownerId: 1,
ownerName: 'Anna',
text: 'Luna genießt die Sonne auf dem Balkon ☀️',
createdAt: 'vor 2 Stunden',
media: {
type: 'image',
url: 'https://placekitten.com/600/600',
aspect: '1:1',
},
likes: 12,
commentsCount: 2,
commentsList: [
{ id: 1, author: 'Lisa', text: 'Wie süß 😍' },
{ id: 2, author: 'Tom', text: 'Typisch Katze!' },
],
},
{
id: 2,
petId: 2,
petName: 'Bello',
petType: 'Hund',
ownerId: 2,
ownerName: 'Markus',
text: 'Spaziergang im Park – jemand hatte sehr viel Energie 🐕',
createdAt: 'heute Morgen',
media: {
type: 'video',
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
aspect: '9:16',
},
likes: 20,
commentsCount: 0,
commentsList: [],
},
];
/* ---------------- PAGE ---------------- */
export default function FeedPage() {
const [selectedOwnerId, setSelectedOwnerId] = useState<number | null>(null);
const visiblePosts = selectedOwnerId
? mockPosts.filter((post) => post.ownerId === selectedOwnerId)
: mockPosts;
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
{/* HEADER */}
<header>
<h1 style={{ marginBottom: '0.3rem' }}>Feed</h1>
<p style={{ margin: 0, color: '#555' }}>
Neueste Beiträge aus der OnlyPets-Community
</p>
</header>
{/* 🔄 Frische Tier-Admins Slider */}
<FreshPostsSlider
users={mockAdmins}
onSelectUser={setSelectedOwnerId}
/>
{/* 📰 FEED */}
<section style={{ display: 'grid', gap: '1.5rem' }}>
{visiblePosts.length === 0 && (
<p style={{ color: '#777' }}>
Keine Beiträge von diesen Tier-Admins.
</p>
)}
{visiblePosts.map((post) => (
<PostCard
key={post.id}
petName={post.petName}
petType={post.petType}
ownerName={post.ownerName}
text={post.text}
createdAt={post.createdAt}
media={post.media}
likes={post.likes}
commentsCount={post.commentsCount}
commentsList={post.commentsList}
/>
))}
</section>
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Navigation */
.navbar {
display: flex;
align-items: center;
gap: 1.5rem;
}
.navbar a {
color: white;
text-decoration: none;
padding: 0.3rem 0.6rem;
border-radius: 6px;
transition: background-color 0.2s ease;
}
.navbar a:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.navbar a.active {
background-color: rgba(255, 255, 255, 0.35);
}
+24
View File
@@ -0,0 +1,24 @@
import './globals.css';
import Navigation from './components/Navigation';
export const metadata = {
title: 'OnlyPets',
description: 'Die Community für Hunde und Katzen',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body>
<Navigation />
<main style={{ padding: '2rem' }}>
{children}
</main>
</body>
</html>
);
}
+26
View File
@@ -0,0 +1,26 @@
import AuthForm from '../components/AuthForm/AuthForm';
export default function LoginPage() {
return (
<AuthForm
title="Anmelden"
description="Melde dich bei OnlyPets an, um Beiträge zu sehen und zu erstellen."
submitLabel="Anmelden"
fields={[
{
label: 'E-Mail',
type: 'email',
placeholder: 'deine@email.de',
},
{
label: 'Passwort',
type: 'password',
placeholder: '••••••••',
},
]}
footerText="Noch keinen Account?"
footerLinkText="Jetzt registrieren"
footerLinkHref="/register"
/>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from 'next/link';
export default function HomePage() {
return (
<section style={{ display: 'grid', gap: '2rem' }}>
<header style={{ display: 'grid', gap: '1rem' }}>
<h1 style={{ fontSize: '2.2rem', margin: 0 }}>
OnlyPets 🐾
</h1>
<p style={{ fontSize: '1.05rem', margin: 0, maxWidth: '60ch' }}>
Die Community für Hunde und Katzen. Teile Momente deiner Haustiere,
entdecke neue Fellnasen und vernetze dich mit Tierfreunden.
</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<Link
href="/register"
style={{
backgroundColor: '#2E7D32',
color: 'white',
textDecoration: 'none',
padding: '0.7rem 1rem',
borderRadius: '10px',
fontWeight: 600,
}}
>
Registrieren
</Link>
<Link
href="/login"
style={{
backgroundColor: '#546E7A',
color: 'white',
textDecoration: 'none',
padding: '0.7rem 1rem',
borderRadius: '10px',
fontWeight: 600,
}}
>
Anmelden
</Link>
</div>
</header>
<section
style={{
display: 'grid',
gap: '1rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
}}
>
<article
style={{
border: '1px solid #e5e5e5',
borderRadius: '14px',
padding: '1rem',
}}
>
<h2 style={{ marginTop: 0 }}>📸 Posts</h2>
<p style={{ marginBottom: 0 }}>
Teile Fotos und Momente deiner Haustiere. Später kommen Likes und Kommentare dazu.
</p>
</article>
<article
style={{
border: '1px solid #e5e5e5',
borderRadius: '14px',
padding: '1rem',
}}
>
<h2 style={{ marginTop: 0 }}>🔍 Entdecken</h2>
<p style={{ marginBottom: 0 }}>
Finde Hunde und Katzen nach Namen, Art und später auch nach Rasse und Filtern.
</p>
</article>
<article
style={{
border: '1px solid #e5e5e5',
borderRadius: '14px',
padding: '1rem',
}}
>
<h2 style={{ marginTop: 0 }}>🐾 Haustiere</h2>
<p style={{ marginBottom: 0 }}>
Lege Profile für deine Tiere an. Ein Nutzer kann mehrere Haustiere haben.
</p>
</article>
</section>
</section>
);
}
+326
View File
@@ -0,0 +1,326 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { dogBreeds, catBreeds } from '@/app/data/breeds';
type PetType = 'Hund' | 'Katze';
type Pet = {
id: number;
name: string;
type: PetType;
breed: string;
bio?: string;
};
// 🔧 Mock-Daten (später Backend)
const mockPets: Pet[] = [
{
id: 1,
name: 'Luna',
type: 'Katze',
breed: 'Bengal x Mix',
bio: 'Neugierig, verspielt und liebt Sonne.',
},
{
id: 2,
name: 'Bello',
type: 'Hund',
breed: 'Labrador Retriever x Mix',
bio: 'Immer gute Laune.',
},
];
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function EditPetPage({ params }: PageProps) {
const { id } = await params;
const petId = Number(id);
const pet = mockPets.find((p) => p.id === petId);
if (!pet) {
return <p>Haustier nicht gefunden.</p>;
}
return <EditPetForm pet={pet} />;
}
/* ---------- FORMULAR (Client Component) ---------- */
function EditPetForm({ pet }: { pet: Pet }) {
const [name, setName] = useState(pet.name);
const [type, setType] = useState<PetType>(pet.type);
const [breed, setBreed] = useState(pet.breed);
const [breedSearch, setBreedSearch] = useState('');
const [bio, setBio] = useState(pet.bio ?? '');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const breeds = type === 'Hund' ? dogBreeds : catBreeds;
const filteredBreeds = breeds.filter((b) =>
b.toLowerCase().includes(breedSearch.toLowerCase())
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError('');
setSuccess('');
if (!name.trim()) {
setError('Name darf nicht leer sein.');
return;
}
if (!breed) {
setError('Bitte wähle eine Rasse aus.');
return;
}
const updatedPetPayload = {
id: pet.id,
name,
type,
breed,
bio: bio || null,
updatedAt: new Date().toISOString(),
};
console.log('Haustier aktualisiert (Mock):', updatedPetPayload);
setSuccess('Änderungen gespeichert ✅');
}
function handleDelete() {
console.log('Haustier gelöscht (Mock):', pet.id);
// Später:
// await fetch(`/api/pets/${pet.id}`, { method: 'DELETE' })
// router.push('/pets');
setShowDeleteConfirm(false);
alert('Haustier wurde technisch gelöscht (Mock)');
}
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<h1>Haustier bearbeiten</h1>
<p style={{ color: '#555' }}>
Passe die Informationen deines Haustiers an.
</p>
</header>
<form
onSubmit={handleSubmit}
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'grid',
gap: '1rem',
backgroundColor: 'white',
}}
>
{/* Name */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Name</strong>
<input
value={name}
onChange={(e) => setName(e.target.value)}
style={{
padding: '0.7rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/>
</div>
{/* Art */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Tier-Art</strong>
<select
value={type}
onChange={(e) => {
setType(e.target.value as PetType);
setBreed('');
setBreedSearch('');
}}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
maxWidth: '220px',
}}
>
<option value="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',
}}
/>
<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}>
{b}
</option>
))}
</select>
</div>
{/* Bio */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Kurzbeschreibung</strong>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
style={{
padding: '0.8rem',
borderRadius: '12px',
border: '1px solid #ccc',
}}
/>
</div>
{/* Meldungen */}
{error && <div style={{ background: '#f8d7da', padding: '0.7rem' }}>{error}</div>}
{success && <div style={{ background: '#d1e7dd', padding: '0.7rem' }}>{success}</div>}
{/* Aktionen */}
<div style={{ display: 'flex', gap: '0.8rem' }}>
<button
type="submit"
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
}}
>
Änderungen speichern
</button>
<Link href={`/pets/${pet.id}`}>Abbrechen</Link>
<hr style={{ margin: '1rem 0', borderColor: '#eee' }} />
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
style={{
padding: '0.7rem 1.2rem',
borderRadius: '12px',
border: '1px solid #d32f2f',
backgroundColor: 'white',
color: '#d32f2f',
fontWeight: 700,
cursor: 'pointer',
}}
>
Haustier löschen
</button>
</div>
</form>
{showDeleteConfirm && (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '1.2rem',
maxWidth: '420px',
width: '100%',
display: 'grid',
gap: '1rem',
}}
>
<h3>Haustier wirklich löschen?</h3>
<p style={{ color: '#555' }}>
Alle Beiträge dieses Haustiers gehen dabei verloren.
Diese Aktion kann nicht rückgängig gemacht werden. Bist du dir sicher?
</p>
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowDeleteConfirm(false)}
style={{
padding: '0.6rem 1rem',
borderRadius: '10px',
border: '1px solid #ccc',
backgroundColor: 'white',
cursor: 'pointer',
}}
>
Abbrechen
</button>
<button
onClick={handleDelete}
style={{
padding: '0.6rem 1rem',
borderRadius: '10px',
border: 'none',
backgroundColor: '#d32f2f',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Löschen
</button>
</div>
</div>
</div>
)}
</section>
);
}
+105
View File
@@ -0,0 +1,105 @@
import PostCard from '@/app/components/PostCard/PostCard';
type Comment = {
id: number;
author: string;
text: string;
};
type PetPost = {
id: number;
text: string;
createdAt: string;
likes: number;
commentsCount: number;
commentsList: Comment[];
};
type Pet = {
id: number;
name: string;
type: 'Hund' | 'Katze';
breed: string;
bio?: string;
posts: PetPost[];
};
const mockPets: Pet[] = [
{
id: 1,
name: 'Luna',
type: 'Katze',
breed: 'Bengal x Mix',
bio: 'Neugierig, verspielt und liebt Sonne.',
posts: [
{
id: 101,
text: 'Luna genießt die Sonne ☀️',
createdAt: 'vor 2 Stunden',
likes: 12,
commentsCount: 2,
commentsList: [
{ id: 1, author: 'Lisa', text: 'Wie süß 😍' },
{ id: 2, author: 'Tom', text: 'Typisch Bengal!' },
],
},
],
},
{
id: 2,
name: 'Bello',
type: 'Hund',
breed: 'Labrador Retriever x Mix',
bio: 'Immer gute Laune.',
posts: [],
},
];
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function PetDetailPage({ params }: PageProps) {
const { id } = await params; // ✅ DAS ist der entscheidende Fix
const petId = Number(id);
console.log('PET ID:', petId);
const pet = mockPets.find((p) => p.id === petId);
if (!pet) {
return <p>Haustier nicht gefunden.</p>;
}
return (
<section style={{ maxWidth: '600px', margin: '0 auto', display: 'grid', gap: '1.5rem' }}>
<header style={{ border: '1px solid #e5e5e5', borderRadius: '16px', padding: '1rem' }}>
<h1>{pet.name}</h1>
<div>{pet.type} · {pet.breed}</div>
{pet.bio && <p>{pet.bio}</p>}
</header>
<section style={{ display: 'grid', gap: '1.2rem' }}>
<h2>Beiträge</h2>
{pet.posts.length === 0 && (
<p style={{ color: '#777' }}>Dieses Haustier hat noch keine Beiträge.</p>
)}
{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>
);
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { dogBreeds, catBreeds } from '@/app/data/breeds';
type PetType = 'Hund' | 'Katze' | '';
export default function NewPetPage() {
const [name, setName] = useState('');
const [type, setType] = useState<PetType>('');
const [breed, setBreed] = useState('');
const [breedSearch, setBreedSearch] = useState('');
const [bio, setBio] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const breeds =
type === 'Hund'
? dogBreeds
: type === 'Katze'
? catBreeds
: [];
const filteredBreeds = breeds.filter((b) =>
b.toLowerCase().includes(breedSearch.toLowerCase())
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError('');
setSuccess('');
if (!name.trim()) {
setError('Bitte gib einen Namen für dein Haustier ein.');
return;
}
if (!type) {
setError('Bitte wähle eine Tier-Art aus.');
return;
}
if (!breed) {
setError('Bitte wähle eine Rasse aus.');
return;
}
const newPetPayload = {
name,
type,
breed,
bio: bio || null,
createdAt: new Date().toISOString(),
};
console.log('Neues Haustier (Mock):', newPetPayload);
setSuccess('Haustier wurde technisch angelegt ✅');
setName('');
setType('');
setBreed('');
setBreedSearch('');
setBio('');
}
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<h1 style={{ marginBottom: '0.5rem' }}>Haustier hinzufügen</h1>
<p style={{ margin: 0, color: '#555' }}>
Lege ein neues Haustier für dein Profil an.
</p>
</header>
<form
onSubmit={handleSubmit}
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'grid',
gap: '1rem',
backgroundColor: 'white',
}}
>
{/* Name */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Name</strong>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z. B. Luna"
style={{
padding: '0.7rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/>
</div>
{/* Art */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Tier-Art</strong>
<select
value={type}
onChange={(e) => {
setType(e.target.value as PetType);
setBreed('');
setBreedSearch('');
}}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
maxWidth: '220px',
}}
>
<option value="">Bitte auswählen</option>
<option value="Katze">Katze</option>
<option value="Hund">Hund</option>
</select>
</div>
{/* Rasse */}
{type && (
<div style={{ display: 'grid', gap: '0.5rem' }}>
<strong>Rasse</strong>
<input
placeholder="Rasse suchen …"
value={breedSearch}
onChange={(e) => setBreedSearch(e.target.value)}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/>
<select
value={breed}
onChange={(e) => setBreed(e.target.value)}
size={Math.min(filteredBreeds.length, 6)}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
>
{filteredBreeds.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
)}
{/* Bio */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Kurzbeschreibung (optional)</strong>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="Alter, Charakter, Besonderheiten …"
rows={3}
style={{
padding: '0.8rem',
borderRadius: '12px',
border: '1px solid #ccc',
}}
/>
</div>
{/* Meldungen */}
{error && (
<div style={{ background: '#f8d7da', padding: '0.7rem' }}>
{error}
</div>
)}
{success && (
<div style={{ background: '#d1e7dd', padding: '0.7rem' }}>
{success}
</div>
)}
{/* Aktionen */}
<div style={{ display: 'flex', gap: '0.8rem' }}>
<button
type="submit"
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
}}
>
Haustier speichern
</button>
<Link href="/pets">Abbrechen</Link>
</div>
</form>
</section>
);
}
+75
View File
@@ -0,0 +1,75 @@
import Link from 'next/link';
type Pet = {
id: number;
name: string;
type: 'Hund' | 'Katze';
};
const mockPets: Pet[] = [
{ id: 1, name: 'Luna', type: 'Katze' },
{ id: 2, name: 'Bello', type: 'Hund' },
];
export default function PetsPage() {
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<h1 style={{ marginBottom: '0.5rem' }}>Meine Haustiere</h1>
<p style={{ margin: 0, color: '#555' }}>
Verwalte deine Hunde und Katzen
</p>
</header>
<section style={{ display: 'grid', gap: '1rem' }}>
{mockPets.map((pet) => (
<Link
key={pet.id}
href={`/pets/${pet.id}`}
style={{
textDecoration: 'none',
color: 'inherit',
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<strong>{pet.name}</strong>
<div style={{ fontSize: '0.9rem', color: '#555' }}>
{pet.type}
</div>
</div>
<span style={{ fontSize: '1.2rem' }}>›</span>
</Link>
))}
</section>
<Link
href="/pets/new"
style={{
alignSelf: 'flex-start',
padding: '0.7rem 1rem',
borderRadius: '12px',
backgroundColor: '#2E7D32',
color: 'white',
textDecoration: 'none',
fontWeight: 600,
}}
>
➕ Haustier hinzufügen
</Link>
</section>
);
}
+413
View File
@@ -0,0 +1,413 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
type Pet = {
id: number;
name: string;
type: 'Hund' | 'Katze';
};
type Media = {
type: 'image' | 'video';
url: string;
aspect: '1:1' | '9:16';
};
/* 🔧 Mock-Pets */
const mockPets: Pet[] = [
{ id: 1, name: 'Luna', type: 'Katze' },
{ id: 2, name: 'Bello', type: 'Hund' },
];
export default function NewPostPage() {
const [petId, setPetId] = useState<number | ''>('');
const [text, setText] = useState('');
const [media, setMedia] = useState<Media | null>(null);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [uploadStatus, setUploadStatus] = useState<
'idle' | 'uploading' | 'processing' | 'done' | 'error'
>('idle');
const DRAFT_KEY = 'onlypets:new-post-draft';
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (draft.petId) setPetId(draft.petId);
if (draft.text) setText(draft.text);
if (draft.media) setMedia(draft.media);
} catch (e) {
console.warn('Draft konnte nicht geladen werden');
}
}, []);
useEffect(() => {
const draft = {
petId,
text,
media,
};
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
}, [petId, text, media]);
function handleFile(file: File) {
setError('');
setUploadStatus('uploading');
const sizeMB = file.size / (1024 * 1024);
if (file.type.startsWith('image/')) {
setMedia({
type: 'image',
url: URL.createObjectURL(file),
aspect: '1:1',
});
// Mock: Upload sofort fertig
setUploadStatus('done');
}
else if (file.type.startsWith('video/')) {
setUploadStatus('processing');
const video = document.createElement('video');
video.preload = 'metadata';
video.src = URL.createObjectURL(file);
video.onloadedmetadata = () => {
setMedia({
type: 'video',
url: video.src,
aspect: '9:16',
});
setUploadStatus('done');
};
}
else {
setError('Nur Bilder oder Videos sind erlaubt.');
setUploadStatus('error');
}
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
if (file.type.startsWith('image/')) {
setMedia({
type: 'image',
url,
aspect: '1:1',
});
} else if (file.type.startsWith('video/')) {
setMedia({
type: 'video',
url,
aspect: '9:16', // Standard, später wählbar
});
} else {
setError('Nur Bilder oder Videos sind erlaubt.');
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess('');
if (!petId) {
setError('Bitte wähle ein Haustier aus.');
return;
}
if (!text.trim() && !media) {
setError('Text oder Media ist erforderlich.');
localStorage.removeItem(DRAFT_KEY);
return;
}
const pet = mockPets.find((p) => p.id === petId);
const postPayload = {
petId,
petName: pet?.name,
text,
media,
createdAt: new Date().toISOString(),
};
console.log('NEUER POST (mock):', postPayload);
setSuccess('Beitrag erstellt (Mock) ✅');
setPetId('');
setText('');
setMedia(null);
}
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.2rem',
}}
>
<header>
<h1>Neuer Beitrag für ein Haustier</h1>
<p style={{ color: '#555' }}>
Teile einen Moment aus dem Leben deines Tieres.
</p>
</header>
<form
onSubmit={handleSubmit}
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'grid',
gap: '1rem',
backgroundColor: 'white',
}}
>
{/* Haustier */}
<div>
<strong>Haustier</strong>
<select
value={petId}
onChange={(e) => setPetId(Number(e.target.value))}
style={{ width: '100%', padding: '0.6rem' }}
>
<option value="">Bitte auswählen</option>
{mockPets.map((pet) => (
<option key={pet.id} value={pet.id}>
{pet.name} ({pet.type})
</option>
))}
</select>
</div>
{/* Media Upload */}
<div>
{/* Media Upload */}
<div style={{ display: 'grid', gap: '0.6rem' }}>
<strong>Foto oder Video</strong>
{!media && (
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
}}
onClick={() => fileInputRef.current?.click()}
style={{
padding: '1.4rem',
borderRadius: '16px',
border: `2px dashed ${isDragging ? '#2E7D32' : '#bbb'}`,
backgroundColor: isDragging ? '#eef7f0' : '#fafafa',
cursor: 'pointer',
fontWeight: 600,
color: '#555',
textAlign: 'center',
transition: 'all 0.15s ease',
}}
>
{isDragging ? 'Hier loslassen' : '➕ Moment teilen'}
{uploadStatus !== 'idle' && (
<div style={{ fontSize: '0.8rem', color: '#555' }}>
{uploadStatus === 'uploading' && 'Moment wird hochgeladen…'}
{uploadStatus === 'processing' && 'Moment wird vorbereitet…'}
{uploadStatus === 'done' && 'Dein Moment ist Online ✨'}
{uploadStatus === 'error' && 'Das hat leider nicht geklappt.'}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
}}
style={{ display: 'none' }}
/>
</div>
)}
</div>
</div>
{/* Media Preview */}
{media && (
<div style={{ display: 'grid', gap: '0.5rem' }}>
<div
style={{
width: '100%',
aspectRatio: media.aspect === '1:1' ? '1 / 1' : '9 / 16',
backgroundColor: '#000',
overflow: 'hidden',
borderRadius: '12px',
}}
>
{media.type === 'image' && (
<img
src={media.url}
alt="Preview"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
{media.type === 'video' && (
<video
src={media.url}
controls
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
</div>
<button
type="button"
onClick={() => {
setMedia(null);
setUploadStatus('idle');
}}
style={{
alignSelf: 'flex-start',
background: 'none',
border: 'none',
color: '#d32f2f',
cursor: 'pointer',
fontWeight: 600,
}}
>
✕ Anderes Foto / Video wählen
</button>
</div>
)}
{/* Text */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Was macht dein Haustier gerade?"
rows={3}
style={{ padding: '0.8rem', borderRadius: '12px' }}
/>
{/* Meldungen */}
{error && <div style={{ color: '#d32f2f' }}>{error}</div>}
{success && <div style={{ color: '#2E7D32' }}>{success}</div>}
{/* Aktionen */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '0.5rem',
}}
>
{/* Abbrechen – NICHT löschen */}
<button
type="button"
onClick={() => {
window.history.back();
}}
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: '2px solid #d32f2f',
backgroundColor: 'transparent',
color: '#d32f2f',
fontWeight: 700,
cursor: 'pointer',
}}
>
Abbrechen
</button>
<div style={{ display: 'flex', gap: '1rem' }}>
{/* Entwurf verwerfen – LÖSCHT */}
<button
type="button"
onClick={() => {
setPetId('');
setText('');
setMedia(null);
setUploadStatus('idle');
localStorage.removeItem(DRAFT_KEY);
}}
style={{
background: 'none',
border: 'none',
color: '#777',
cursor: 'pointer',
fontSize: '0.8rem',
}}
>
Entwurf verwerfen
</button>
{/* Posten – PRIMARY */}
<button
type="submit"
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Posten
</button>
</div>
</div>
</form>
</section>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function ProfilePage() {
return <h1>Profil</h1>;
}
+31
View File
@@ -0,0 +1,31 @@
import AuthForm from '../components/AuthForm/AuthForm';
export default function RegisterPage() {
return (
<AuthForm
title="Registrieren"
description="Erstelle deinen OnlyPets-Account und werde Teil der Community."
submitLabel="Account erstellen"
fields={[
{
label: 'Benutzername',
type: 'text',
placeholder: 'z. B. tierfreund123',
},
{
label: 'E-Mail',
type: 'email',
placeholder: 'deine@email.de',
},
{
label: 'Passwort',
type: 'password',
placeholder: '••••••••',
},
]}
footerText="Bereits einen Account?"
footerLinkText="Jetzt anmelden"
footerLinkHref="/login"
/>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function SettingsPage() {
return <h1>Einstellungen</h1>;
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+6538
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}