Adds Docker setup and basic API endpoints

WHAT: Introduces Dockerfiles for development and production, Docker Compose configurations, a Makefile for common tasks, shell scripts for environment setup/teardown, a basic Express API with SQLite integration.
WHY: Enables easy setup and deployment of the application using Docker. Provides basic API endpoints for managing purchase items.
HOW:
- Creates `docker-compose.dev.yml` and `docker-compose.prod.yml` to define services and volumes.
- Introduces `Dockerfile.dev` and `Dockerfile.prod` to build container images with necessary dependencies.
- Adds `Makefile` with commands for building, running, and managing the application.
- Implements shell scripts for simplified Docker environment management.
- Sets up Express API with endpoints for CRUD operations on purchase items, using SQLite as the database.
- Uses `better-sqlite3` to connect and interact with the SQLite database.
This commit is contained in:
Chenwei Jiang 2025-08-21 14:46:11 +08:00
parent 3b638a9509
commit 4557728932
Signed by: cheverjohn
GPG key ID: ADC4815BFE960182
20 changed files with 1955 additions and 20 deletions

43
src/lib/api.ts Normal file
View file

@ -0,0 +1,43 @@
import type { PurchaseItem as StoreItem } from '@/store/purchase-store';
const API_BASE = import.meta.env.VITE_API_BASE || '';
export async function fetchItems(): Promise<StoreItem[]> {
const res = await fetch(`${API_BASE}/api/items`);
if (!res.ok) throw new Error('Failed to fetch items');
return res.json();
}
export async function createItem(item: StoreItem): Promise<void> {
const res = await fetch(`${API_BASE}/api/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (!res.ok) throw new Error('Failed to create item');
}
export async function updateItem(item: StoreItem): Promise<void> {
const res = await fetch(`${API_BASE}/api/items/${encodeURIComponent(item.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (!res.ok) throw new Error('Failed to update item');
}
export async function deleteItemById(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/api/items/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete item');
}
export async function togglePurchased(id: string, purchased: boolean): Promise<void> {
const res = await fetch(`${API_BASE}/api/items/${encodeURIComponent(id)}/purchased`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ purchased }),
});
if (!res.ok) throw new Error('Failed to toggle purchased');
}

View file

@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
import { DogMotivation } from "@/features/purchase/components/DogMotivation";
function HomePage() {
const { items, totalBudget, getPurchasedTotal, getRemainingTotal } = usePurchaseStore();
const { items, totalBudget, getPurchasedTotal, getRemainingTotal, hydrateFromServer } = usePurchaseStore();
const [currentDate, setCurrentDate] = useState("");
const purchasedTotal = getPurchasedTotal();
@ -29,6 +29,11 @@ function HomePage() {
setCurrentDate(formattedDate);
}, []);
useEffect(() => {
// hydrate from backend once
hydrateFromServer();
}, [hydrateFromServer]);
const handlePrint = () => {
window.print();
};

View file

@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import * as api from '@/lib/api';
export type PurchaseItem = {
id: string;
@ -30,10 +31,11 @@ type PurchaseStore = {
setTotalBudget: (budget: number) => void;
setExchangeRates: (rates: { SGD?: number; USD?: number }) => void;
recalculatePrices: () => void;
addItem: (item: PurchaseItem) => void;
updateItem: (item: PurchaseItem) => void;
deleteItem: (id: string) => void;
togglePurchased: (id: string) => void;
hydrateFromServer: () => Promise<void>;
addItem: (item: PurchaseItem) => Promise<void>;
updateItem: (item: PurchaseItem) => Promise<void>;
deleteItem: (id: string) => Promise<void>;
togglePurchased: (id: string) => Promise<void>;
// Calculations
getPurchasedTotal: () => number;
@ -224,21 +226,57 @@ export const usePurchaseStore = create<PurchaseStore>()(
})
});
},
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
updateItem: (item) =>
set((state) => ({
items: state.items.map((i) => (i.id === item.id ? item : i)),
})),
deleteItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
togglePurchased: (id) =>
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, purchased: !item.purchased } : item
),
})),
hydrateFromServer: async () => {
try {
const serverItems = await api.fetchItems();
set({ items: serverItems });
} catch (e) {
console.warn('hydrateFromServer failed, using local data');
}
},
addItem: async (item) => {
set((state) => ({ items: [...state.items, item] }));
try {
await api.createItem(item);
} catch (e) {
// rollback
set((state) => ({ items: state.items.filter((i) => i.id !== item.id) }));
throw e;
}
},
updateItem: async (item) => {
const prev = get().items;
set({ items: prev.map((i) => (i.id === item.id ? item : i)) });
try {
await api.updateItem(item);
} catch (e) {
set({ items: prev });
throw e;
}
},
deleteItem: async (id) => {
const prev = get().items;
set({ items: prev.filter((i) => i.id !== id) });
try {
await api.deleteItemById(id);
} catch (e) {
set({ items: prev });
throw e;
}
},
togglePurchased: async (id) => {
const prev = get().items;
const next = prev.map((i) => (i.id === id ? { ...i, purchased: !i.purchased } : i));
set({ items: next });
try {
const item = next.find((i) => i.id === id)!;
await api.togglePurchased(id, item.purchased);
} catch (e) {
set({ items: prev });
throw e;
}
},
getPurchasedTotal: () =>
get().items.reduce(
(sum, item) => sum + (item.purchased ? item.price : 0),