From 60d0888a49ece117d01532e0bedc798c6bc96f4c Mon Sep 17 00:00:00 2001 From: Norbert Maciaszek Date: Tue, 11 Nov 2025 15:25:29 +0100 Subject: [PATCH] Add initial project setup with PocketBase integration, global state management, and UI components for gift tracking --- .prettierrc.json | 14 ++ package-lock.json | 43 +++- package.json | 12 +- src/app/globals.css | 23 +-- src/app/layout.tsx | 31 ++- src/app/page.tsx | 77 ++------ src/app/store/global.tsx | 32 +++ src/app/year/[year]/page.tsx | 24 +++ src/components/atoms/Badge/index.tsx | 11 ++ src/components/atoms/Button/index.tsx | 34 ++++ src/components/atoms/Heading/index.tsx | 22 +++ src/components/atoms/Modal/index.tsx | 46 +++++ src/components/atoms/StatsCard/index.tsx | 17 ++ src/components/atoms/YearBudget/index.tsx | 14 ++ src/components/atoms/YearHeader/index.tsx | 21 ++ src/components/atoms/YearList/index.tsx | 21 ++ src/components/atoms/YearNotes/index.tsx | 14 ++ src/components/molecules/ActionCard/index.tsx | 22 +++ src/components/molecules/GiftCard/index.tsx | 91 +++++++++ src/components/molecules/GiftModal/index.tsx | 184 ++++++++++++++++++ .../molecules/PersonCard/client.tsx | 71 +++++++ src/components/molecules/PersonCard/index.tsx | 36 ++++ .../molecules/PersonModal/index.tsx | 88 +++++++++ .../organisms/YearControls/index.tsx | 31 +++ .../organisms/YearOverview/index.tsx | 51 +++++ src/helpers/formatCurrency/index.ts | 7 + src/helpers/formatStatus/index.ts | 12 ++ src/lib/db/index.ts | 106 ++++++++++ src/lib/db/types.d.ts | 35 ++++ src/types/global.d.ts | 61 ++++++ 30 files changed, 1140 insertions(+), 111 deletions(-) create mode 100644 .prettierrc.json create mode 100644 src/app/store/global.tsx create mode 100644 src/app/year/[year]/page.tsx create mode 100644 src/components/atoms/Badge/index.tsx create mode 100644 src/components/atoms/Button/index.tsx create mode 100644 src/components/atoms/Heading/index.tsx create mode 100644 src/components/atoms/Modal/index.tsx create mode 100644 src/components/atoms/StatsCard/index.tsx create mode 100644 src/components/atoms/YearBudget/index.tsx create mode 100644 src/components/atoms/YearHeader/index.tsx create mode 100644 src/components/atoms/YearList/index.tsx create mode 100644 src/components/atoms/YearNotes/index.tsx create mode 100644 src/components/molecules/ActionCard/index.tsx create mode 100644 src/components/molecules/GiftCard/index.tsx create mode 100644 src/components/molecules/GiftModal/index.tsx create mode 100644 src/components/molecules/PersonCard/client.tsx create mode 100644 src/components/molecules/PersonCard/index.tsx create mode 100644 src/components/molecules/PersonModal/index.tsx create mode 100644 src/components/organisms/YearControls/index.tsx create mode 100644 src/components/organisms/YearOverview/index.tsx create mode 100644 src/helpers/formatCurrency/index.ts create mode 100644 src/helpers/formatStatus/index.ts create mode 100644 src/lib/db/index.ts create mode 100644 src/lib/db/types.d.ts create mode 100644 src/types/global.d.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..91f032a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,14 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "always", + "bracketSpacing": true, + "semi": true, + "tabWidth": 2, + "useTabs": false, + "printWidth": 120, + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "jsxBracketSameLine": true +} diff --git a/package-lock.json b/package-lock.json index d41445f..c16c628 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "next": "16.0.1", + "pocketbase": "^0.26.3", "react": "19.2.0", - "react-dom": "19.2.0" + "react-dom": "19.2.0", + "zustand": "^5.0.8" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1023,7 +1025,7 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1079,7 +1081,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -1498,6 +1500,12 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/pocketbase": { + "version": "0.26.3", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.3.tgz", + "integrity": "sha512-5deUKRoEczpxxuHzwr6/DHVmgbggxylEVig8CKN+MjvtYxPUqX/C6puU0yaR2yhTi8zrh7J9s7Ty+qBGwVzWOQ==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1691,6 +1699,35 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 34c4478..d48593a 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,19 @@ "start": "next start" }, "dependencies": { + "next": "16.0.1", + "pocketbase": "^0.26.3", "react": "19.2.0", "react-dom": "19.2.0", - "next": "16.0.1" + "zustand": "^5.0.8" }, "devDependencies": { - "babel-plugin-react-compiler": "1.0.0", - "typescript": "^5", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4" + "babel-plugin-react-compiler": "1.0.0", + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..1f756a4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,5 @@ @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; + @apply bg-gray-50 text-gray-800 font-sans antialiased; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..a1fce86 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,20 +1,9 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { Metadata } from 'next'; +import './globals.css'; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'Gift Tracker', + description: 'Track and manage your gifts', }; export default function RootLayout({ @@ -23,11 +12,13 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} + + +
+
+
{children}
+
+
); diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..f3726c0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,20 @@ -import Image from "next/image"; +import { YearOverview } from '@/components/organisms/YearOverview'; +import { YearList } from '@/components/atoms/YearList'; +import { DB } from '@/lib/db'; +import { GiftCard } from '@/components/molecules/GiftCard'; +import { Heading } from '@/components/atoms/Heading'; + +export default async function Home() { + const recentGifts = await DB.getRecentGifts(); -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
+ <> + + + Ostatnie prezenty + {recentGifts.map((gift) => ( + + ))} + ); } diff --git a/src/app/store/global.tsx b/src/app/store/global.tsx new file mode 100644 index 0000000..59097d3 --- /dev/null +++ b/src/app/store/global.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useEffect } from 'react'; +import { create } from 'zustand'; + +type Props = { + year: Year; + persons: Person[]; + gifts: Gift[]; +}; + +type State = Props & { + hydrate: (data: Partial) => void; +}; + +export const useGlobalStore = create((set) => ({ + year: {} as Year, + persons: [] as Person[], + gifts: [] as Gift[], + + hydrate: (data: Partial) => set(data), +})); + +export const GlobalStore = ({ children, year, persons, gifts }: Partial & { children: React.ReactNode }) => { + const hydrate = useGlobalStore((s) => s.hydrate); + + useEffect(() => { + hydrate({ year, persons, gifts }); + }, []); + + return children; +}; diff --git a/src/app/year/[year]/page.tsx b/src/app/year/[year]/page.tsx new file mode 100644 index 0000000..874d354 --- /dev/null +++ b/src/app/year/[year]/page.tsx @@ -0,0 +1,24 @@ +import { YearOverview } from '@/components/organisms/YearOverview'; +import { PersonCard } from '@/components/molecules/PersonCard'; +import { YearList } from '@/components/atoms/YearList'; +import { DB } from '@/lib/db'; +import { YearControls } from '@/components/organisms/YearControls'; +import { GlobalStore } from '@/app/store/global'; + +export default async function YearPage({ params }: { params: Promise<{ year: string }> }) { + const { year } = await params; + + const data = await DB.getYear(Number(year)); + const persons = await DB.getPersons(data.id); + + return ( + + + + + {persons.map((person) => ( + + ))} + + ); +} diff --git a/src/components/atoms/Badge/index.tsx b/src/components/atoms/Badge/index.tsx new file mode 100644 index 0000000..9665b00 --- /dev/null +++ b/src/components/atoms/Badge/index.tsx @@ -0,0 +1,11 @@ +import { FC } from 'react'; + +type Props = { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; +}; + +export const Badge: FC = ({ children, onClick, disabled }) => { + return {children}; +}; diff --git a/src/components/atoms/Button/index.tsx b/src/components/atoms/Button/index.tsx new file mode 100644 index 0000000..bdc4bcd --- /dev/null +++ b/src/components/atoms/Button/index.tsx @@ -0,0 +1,34 @@ +import { FC } from 'react'; +import Link from 'next/link'; + +type Props = { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; + disabled?: boolean; + variant?: keyof typeof variants; + href?: string; +}; +export const Button: FC = ({ children, onClick, disabled, variant = 'primary', href }) => { + if (href) { + return ( + + {children} + + ); + } + + return ( + + ); +}; + +const variants = { + primary: + 'px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed', + secondary: + 'px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-100 rounded-xl transition-all duration-200 ease-in-out', + danger: + 'px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed', +}; diff --git a/src/components/atoms/Heading/index.tsx b/src/components/atoms/Heading/index.tsx new file mode 100644 index 0000000..eaf63a2 --- /dev/null +++ b/src/components/atoms/Heading/index.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react'; + +type Props = { + size?: 'small' | 'medium' | 'large'; + children: React.ReactNode; + spacing?: 'none' | 'small' | 'medium' | 'large'; +}; + +export const Heading: FC = ({ children, size = 'medium', spacing = 'medium' }) => { + const sizeClass = { + small: 'text-lg', + medium: 'text-2xl', + large: 'text-3xl', + }; + const spacingClass = { + none: 'mb-0', + small: 'mb-2', + medium: 'mb-4', + large: 'mb-6', + }; + return

{children}

; +}; diff --git a/src/components/atoms/Modal/index.tsx b/src/components/atoms/Modal/index.tsx new file mode 100644 index 0000000..5a58d08 --- /dev/null +++ b/src/components/atoms/Modal/index.tsx @@ -0,0 +1,46 @@ +'use client'; +import { FC, ReactNode, useEffect } from 'react'; + +type Props = { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + footer?: ReactNode; +}; + +export const Modal: FC = ({ isOpen, onClose, title, children, footer }) => { + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
{children}
+ {footer && ( +
{footer}
+ )} +
+
+ ); +}; diff --git a/src/components/atoms/StatsCard/index.tsx b/src/components/atoms/StatsCard/index.tsx new file mode 100644 index 0000000..bb1ff64 --- /dev/null +++ b/src/components/atoms/StatsCard/index.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; + +type Props = { + title: string; + value: number | string; + description?: string; +}; + +export const StatsCard: FC = ({ title, value, description }) => { + return ( +
+
{title}
+
{value}
+ {description &&
{description}
} +
+ ); +}; diff --git a/src/components/atoms/YearBudget/index.tsx b/src/components/atoms/YearBudget/index.tsx new file mode 100644 index 0000000..6abcb36 --- /dev/null +++ b/src/components/atoms/YearBudget/index.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; + +type Props = { + budget: number; +}; + +export const YearBudget: FC = ({ budget }) => { + return ( +
+

Budżet

+

{budget}

+
+ ); +}; diff --git a/src/components/atoms/YearHeader/index.tsx b/src/components/atoms/YearHeader/index.tsx new file mode 100644 index 0000000..b08df04 --- /dev/null +++ b/src/components/atoms/YearHeader/index.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; + +type Props = { + year: number; + onCopyPeople?: () => void; + isCopying?: boolean; +}; + +export const YearHeader: FC = ({ year, onCopyPeople, isCopying = false }) => { + return ( +
+

Rok {year}

+ +
+ ); +}; diff --git a/src/components/atoms/YearList/index.tsx b/src/components/atoms/YearList/index.tsx new file mode 100644 index 0000000..9e75399 --- /dev/null +++ b/src/components/atoms/YearList/index.tsx @@ -0,0 +1,21 @@ +import { DB } from '@/lib/db'; +import { Button } from '../Button'; + +export const YearList = async () => { + const years = await DB.getYears(); + + return ( +
+
+ + {years.map((year) => ( + + ))} +
+
+ ); +}; diff --git a/src/components/atoms/YearNotes/index.tsx b/src/components/atoms/YearNotes/index.tsx new file mode 100644 index 0000000..d5f5691 --- /dev/null +++ b/src/components/atoms/YearNotes/index.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; + +type Props = { + notes: string; +}; + +export const YearNotes: FC = ({ notes }) => { + return ( +
+

Notes

+

{notes}

+
+ ); +}; diff --git a/src/components/molecules/ActionCard/index.tsx b/src/components/molecules/ActionCard/index.tsx new file mode 100644 index 0000000..0ecc8ec --- /dev/null +++ b/src/components/molecules/ActionCard/index.tsx @@ -0,0 +1,22 @@ +import { Heading } from '@/components/atoms/Heading'; +import { FC } from 'react'; + +type Props = { + title: string; + description?: string; + backgroundColor?: string; + onClick: () => void; +}; + +export const ActionCard: FC = ({ title, description, backgroundColor = 'bg-white', onClick }) => { + return ( +
+ + {title} + + {description &&

{description}

} +
+ ); +}; diff --git a/src/components/molecules/GiftCard/index.tsx b/src/components/molecules/GiftCard/index.tsx new file mode 100644 index 0000000..6bf393d --- /dev/null +++ b/src/components/molecules/GiftCard/index.tsx @@ -0,0 +1,91 @@ +'use client'; +import { FC, useState } from 'react'; +import { formatCurrency } from '@/helpers/formatCurrency'; +import { formatStatus } from '@/helpers/formatStatus'; +import { GiftModal } from '../GiftModal'; +import { useGlobalStore } from '@/app/store/global'; +import { DB } from '@/lib/db'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/atoms/Button'; + +type Props = { + hideDetails?: boolean; + editable?: boolean; + personId?: string; +}; + +export const GiftCard: FC = ({ hideDetails = false, editable = false, personId, ...gift }) => { + const [isOpen, setIsOpen] = useState(false); + const year = useGlobalStore((s) => s.year); + const router = useRouter(); + + const { title, description, cost, status, created, expand } = gift; + + const bgByStatus = { + planned: 'bg-gray-100', + decided: 'bg-blue-100', + bought: 'bg-green-100', + wrapped: 'bg-purple-100', + }; + + const handleDelete = async () => { + await DB.deleteGift(gift.id); + router.refresh(); + }; + + return ( + <> +
setIsOpen(true) : undefined}> +
+
+
+

{title}

+
+ {description &&

{description}

} +
+ Data dodania: {new Date(created).toLocaleDateString()} + + Koszt: {formatCurrency(cost)} + {!hideDetails && ( + <> + + Dla: {expand?.person.name} + + )} + + Status: {formatStatus(status)} + {editable && ( +
+ +
+ )} +
+
+
+
+ + setIsOpen(false)} + yearId={year.id} + personId={personId} + gift={gift} + onSave={async (data) => { + await DB.updateGift(gift.id, data); + router.refresh(); + }} + /> + + ); +}; diff --git a/src/components/molecules/GiftModal/index.tsx b/src/components/molecules/GiftModal/index.tsx new file mode 100644 index 0000000..48c64fc --- /dev/null +++ b/src/components/molecules/GiftModal/index.tsx @@ -0,0 +1,184 @@ +'use client'; +import { FC, useState, useEffect } from 'react'; +import { Modal } from '@/components/atoms/Modal'; +import { Button } from '@/components/atoms/Button'; +import { formatStatus } from '@/helpers/formatStatus'; + +type Props = { + isOpen: boolean; + onClose: () => void; + gift?: Gift; + yearId: string; + personId?: string; + onSave: (data: { + title: string; + description: string; + link: string; + cost: number; + imageUrl: string; + status: 'planned' | 'decided' | 'bought' | 'wrapped'; + year: string; + person: string; + }) => Promise; +}; + +export const GiftModal: FC = ({ isOpen, onClose, gift, yearId, personId, onSave }) => { + const [title, setTitle] = useState(gift?.title || ''); + const [description, setDescription] = useState(gift?.description || ''); + const [link, setLink] = useState(gift?.link || ''); + const [cost, setCost] = useState(gift?.cost || 0); + const [imageUrl, setImageUrl] = useState(gift?.imageUrl || ''); + const [status, setStatus] = useState(gift?.status || 'planned'); + const [selectedPersonId, setSelectedPersonId] = useState(personId || ''); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (gift) { + setTitle(gift.title); + setDescription(gift.description || ''); + setLink(gift.link || ''); + setCost(gift.cost); + setImageUrl(gift.imageUrl || ''); + setStatus(gift.status); + setSelectedPersonId(gift.person); + } else { + setTitle(''); + setDescription(''); + setLink(''); + setCost(0); + setImageUrl(''); + setStatus('planned'); + setSelectedPersonId(personId || ''); + } + }, [gift, personId, isOpen]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + await onSave({ + title, + description, + link, + cost, + imageUrl, + status, + year: yearId, + person: selectedPersonId, + }); + onClose(); + } catch (error) { + console.error('Error saving gift:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + }> +
+
+ + setTitle(e.target.value)} + className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all' + required + disabled={isLoading} + /> +
+
+ +