diff --git a/package-lock.json b/package-lock.json
index d93b375..2a961c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@auth/prisma-adapter": "^2.11.0",
+ "@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.17.1",
"bcryptjs": "^3.0.2",
@@ -255,6 +256,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@heroicons/react": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
+ "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">= 16 || ^19.0.0-rc"
+ }
+ },
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
@@ -2421,6 +2431,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
"engines": {
"node": ">=6"
}
diff --git a/package.json b/package.json
index 43d6405..15e0e12 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.0",
+ "@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.17.1",
"bcryptjs": "^3.0.2",
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 427203b..7fd3a88 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import SessionProvider from "@/components/providers/SessionProvider";
+import AppShell from "@/components/layout/AppShell";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -25,10 +26,10 @@ export default function RootLayout({
}>) {
return (
-
- {children}
+
+
+ {children}
+
);
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..aa98824
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,34 @@
+import { LoadingSpinner, Skeleton } from "@/components/ui";
+
+export default function Loading() {
+ return (
+
+ {/* mały spinner na górze */}
+
+
+
+
+ {/* skeleton hero */}
+
+
+
+
+
+
+ {/* skeleton statystyki */}
+
+
+
+
+
+
+ {/* skeleton lista ogłoszeń */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index be62a83..72f965f 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,144 +1,178 @@
-import { auth } from "@/lib/auth"
-import { prisma } from "@/lib/prisma"
-import Link from "next/link"
-import { signOut } from "@/lib/auth"
+import { auth, signOut } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import Link from "next/link";
export default async function Home() {
- const session = await auth()
-
- // Fetch some posts from database
+ const session = await auth();
+
const posts = await prisma.post.findMany({
take: 3,
- orderBy: { createdAt: 'desc' },
+ orderBy: { createdAt: "desc" },
include: {
author: {
select: {
name: true,
email: true,
- }
+ },
},
_count: {
- select: { comments: true }
- }
- }
- })
+ select: { comments: true },
+ },
+ },
+ });
- return (
-
- {/* Header */}
-
+ const totalComments = posts.reduce(
+ (sum, post) => sum + post._count.comments,
+ 0
+ );
- {/* Main Content */}
-
- {/* Welcome Section */}
-
-
- {session ? `Witaj ponownie! 👋` : 'Witamy w LocalAid! 🎉'}
-
-
- LocalAid to platforma łącząca sąsiadów, którzy potrzebują pomocy z tymi, którzy mogą jej udzielić.
- Pożycz narzędzie, pomóż w zakupach, lub znajdź kogoś kto pomoże w transporcie.
+ return (
+
+ {/* Welcome / hero + akcje */}
+
+
+
+ {session ? "Witaj ponownie! 👋" : "Witamy w LocalAid! 🎉"}
+
+
+ LocalAid to platforma łącząca sąsiadów, którzy potrzebują pomocy z
+ tymi, którzy mogą jej udzielić. Pożycz narzędzie, pomóż w zakupach,
+ albo znajdź kogoś, kto pomoże w transporcie.
+
{!session && (
-
-
🧪 Demo - konta testowe:
-
Email: jan.kowalski@example.com
-
Hasło: password123
+
+
🧪 Konto demo:
+
+ Email:{" "}
+
+ jan.kowalski@example.com
+
+
+
+ Hasło:{" "}
+
+ password123
+
+
)}
- {/* Stats */}
-
-
-
{posts.length}
-
Aktywnych ogłoszeń
-
-
-
- {posts.reduce((sum, post) => sum + post._count.comments, 0)}
-
-
Komentarzy
+
+ {session ? (
+ <>
+
+ >
+ ) : (
+ <>
+
+ Zaloguj się
+
+
+ Zarejestruj się
+
+ >
+ )}
+
+
+
+ {/* Statystyki */}
+
+
+
+ {posts.length}
-
-
3
-
Użytkowników
+
Aktywnych ogłoszeń
+
+
+
+ {totalComments}
+
Komentarzy
+
+
+
3
+
Użytkowników (demo)
+
+
+
+ {/* Ostatnie ogłoszenia */}
+
+
+
+ 📋 Najnowsze ogłoszenia
+
+
+ Zobacz wszystkie
+
- {/* Recent Posts */}
-
-
📋 Najnowsze ogłoszenia
+ {posts.length === 0 ? (
+
+ Brak ogłoszeń. Dodaj pierwsze ogłoszenie, aby zacząć.
+
+ ) : (
{posts.map((post) => (
-
-
{post.title}
-
{post.description}
-
-
👤 {post.author.name}
+
+
+ {post.title}
+
+
+ {post.description}
+
+
+ 👤 {post.author.name ?? post.author.email}
📁 {post.category}
💬 {post._count.comments} komentarzy
- 🕒 {new Date(post.createdAt).toLocaleDateString('pl-PL')}
+
+ 🕒{" "}
+ {new Date(post.createdAt).toLocaleDateString("pl-PL", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ })}
+
))}
-
+ )}
+
- {/* Database Connection Test */}
-
-
- ✅ Baza danych działa poprawnie!
-
-
- Połączenie z SQLite zostało nawiązane. Załadowano {posts.length} ogłoszenia.
-
-
-
+ {/* Info o bazie – jako mały banner */}
+
+ ✅ Baza danych działa poprawnie
+
+ Połączenie z SQLite zostało nawiązane. Załadowano {posts.length}{" "}
+ ogłoszenia.
+
+
- )
+ );
}
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
new file mode 100644
index 0000000..c6957eb
--- /dev/null
+++ b/src/components/layout/AppShell.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import Navbar from "@/components/layout/Navbar";
+import Sidebar from "@/components/layout/Sidebar";
+
+export default function AppShell({ children }: { children: React.ReactNode }) {
+ const [collapsed, setCollapsed] = useState(false);
+
+ // Wczytanie stanu sidebaru z localStorage
+ useEffect(() => {
+ try {
+ const stored = window.localStorage.getItem("localaid-sidebar-collapsed");
+ if (stored === "true") {
+ setCollapsed(true);
+ }
+ } catch {
+ // ignorujemy
+ }
+ }, []);
+
+ const handleToggle = () => {
+ setCollapsed((prev) => {
+ const next = !prev;
+ try {
+ window.localStorage.setItem(
+ "localaid-sidebar-collapsed",
+ next ? "true" : "false"
+ );
+ } catch {
+ // ignorujemy
+ }
+ return next;
+ });
+ };
+
+ return (
+
+ {/* Sidebar – pełna wysokość */}
+
+
+ {/* Prawa część */}
+
+
+ );
+}
diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx
new file mode 100644
index 0000000..f08c022
--- /dev/null
+++ b/src/components/layout/Navbar.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import React from "react";
+import { BellIcon } from "@heroicons/react/24/outline";
+import Button from "@/components/ui/Button";
+
+export default function Navbar() {
+ return (
+
+ );
+}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..03957db
--- /dev/null
+++ b/src/components/layout/Sidebar.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import Button from "@/components/ui/Button";
+import {
+ HomeIcon,
+ MapIcon,
+ ClipboardDocumentListIcon,
+ UserCircleIcon,
+ Cog6ToothIcon,
+ PlusIcon,
+ ChevronDoubleLeftIcon,
+ ChevronDoubleRightIcon,
+} from "@heroicons/react/24/outline";
+
+type NavItem = {
+ label: string;
+ href: string;
+ icon: React.ComponentType
>;
+};
+
+const mainNav: NavItem[] = [
+ { label: "Przegląd", href: "/", icon: HomeIcon },
+ { label: "Ogłoszenia", href: "/posts", icon: ClipboardDocumentListIcon },
+ { label: "Mapa", href: "/map", icon: MapIcon },
+];
+
+const accountNav: NavItem[] = [
+ { label: "Profil", href: "/profile", icon: UserCircleIcon },
+ { label: "Ustawienia", href: "/settings", icon: Cog6ToothIcon },
+];
+
+export default function Sidebar({
+ collapsed,
+ onToggle,
+}: {
+ collapsed: boolean;
+ onToggle: () => void;
+}) {
+ const pathname = usePathname();
+ const router = useRouter();
+
+ const renderItem = (item: NavItem) => {
+ const Icon = item.icon;
+ const isActive =
+ item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
+
+ return (
+
+
+ {!collapsed && {item.label} }
+
+ );
+ };
+
+ return (
+
+
+
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Środkowa część – scrollowalna, żeby nic się nie ucinało */}
+
+
{mainNav.map(renderItem)}
+
+
+ {accountNav.map(renderItem)}
+
+
+
+ {/* Stały bottom bar – zawsze widoczny */}
+
+ }
+ fullWidth={!collapsed}
+ className={collapsed ? "h-10 w-10 p-0 mx-auto" : "w-full"}
+ onClick={() => router.push("/posts/create")}
+ title="Dodaj ogłoszenie"
+ >
+ {!collapsed && "Dodaj ogłoszenie"}
+
+
+
+ );
+}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
new file mode 100644
index 0000000..e2448ea
--- /dev/null
+++ b/src/components/ui/Button.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import React from "react";
+import clsx from "clsx";
+
+type ButtonVariant = "primary" | "secondary" | "danger" | "icon";
+
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ variant?: ButtonVariant;
+ children?: React.ReactNode; // może być pusty przy przycisku-ikonce
+ iconLeft?: React.ReactNode;
+ iconRight?: React.ReactNode;
+ fullWidth?: boolean;
+}
+
+const baseClasses =
+ "inline-flex items-center justify-center gap-2 rounded-lg font-medium text-sm transition active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed focus:outline-none";
+
+const variantClasses: Record = {
+ primary: "bg-indigo-600 text-white hover:bg-indigo-500 shadow-sm",
+ secondary:
+ "bg-slate-100 text-slate-700 hover:bg-slate-200 border border-slate-200",
+ danger: "bg-rose-600 text-white hover:bg-rose-500 shadow-sm",
+ icon: `
+ bg-transparent
+ text-slate-600
+ hover:bg-slate-100
+ rounded-full
+ `,
+};
+
+export default function Button({
+ variant = "primary",
+ children,
+ iconLeft,
+ iconRight,
+ fullWidth = false,
+ className,
+ ...props
+}: ButtonProps) {
+ return (
+
+ {iconLeft && {iconLeft} }
+ {children && {children} }
+ {iconRight && {iconRight} }
+
+ );
+}
diff --git a/src/components/ui/LoadingSpinner.tsx b/src/components/ui/LoadingSpinner.tsx
new file mode 100644
index 0000000..f907132
--- /dev/null
+++ b/src/components/ui/LoadingSpinner.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+export default function LoadingSpinner() {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx
new file mode 100644
index 0000000..dafb1db
--- /dev/null
+++ b/src/components/ui/Skeleton.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import clsx from "clsx";
+
+export default function Skeleton({
+ className,
+}: {
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
new file mode 100644
index 0000000..e89aa0e
--- /dev/null
+++ b/src/components/ui/index.ts
@@ -0,0 +1,3 @@
+export { default as Button } from "./Button";
+export { default as Skeleton } from "./Skeleton";
+export { default as LoadingSpinner } from "./LoadingSpinner";