diff --git a/.gitignore b/.gitignore index 3d70248ba2..053370cc3a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,11 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -package-lock.json \ No newline at end of file +package-lock.json +# env files +backend/.env +frontend/.env + +# env files +backend/.env +frontend/.env diff --git a/README.md b/README.md index 31466b54c2..75915c1c95 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,42 @@ # Final Project -Replace this readme with your own information about your project. +Pocket Oracle + +A small full-stack tarot app where you can draw cards, save readings, and revisit them later. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. ## The problem -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +Planning: wrote a few user stories (“draw one card”, “save a 3-card reading”, “see my history”), sketched the pages, then built backend → frontend → deploy in that order. + +Tech stack: + +Frontend: React (Vite), React Router, axios, react-hook-form + Zod, styled-components for theme tokens. + +Backend: Node + Express, MongoDB (Mongoose), JWT auth, bcrypt. + +Deployment: Netlify (frontend) and Render (backend). + +How I solved it: + +Created REST endpoints for auth and readings (CRUD). + +Used a shared axios instance with a request interceptor to attach the JWT to every call. + +Set up CORS to allow Netlify + localhost. +Kept colors/spacing in CSS variables so the hero image + palette were easy to tune. + +If I had more time: find /make better readings, implement AI, fix so all tarot cards show, daily card with history - calenderview, search/filter for readings, better a11y & loading states, overall visual clean-up - make it more user logical and a small test suite (Vitest/RTL). + ## View it live -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +App: https://pocketoracle.netlify.app + +API (Render): https://pocket-oracle.onrender.com + + + +Thanks to: +https://github.com/krates98/tarotcardapi for images and more. +https://commons.wikimedia.org/wiki/File:Sebastian_Pether_(1790-1844)_-_Moonlit_Landscape_with_a_Gothic_Ruin_-_1449048_-_National_Trust.jpg for hero image \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..226a9fcc6c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,15 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "axios": "^1.11.0", + "bcrypt": "^6.0.0", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "dotenv": "^17.2.1", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.10" } -} \ No newline at end of file +} diff --git a/backend/server.js b/backend/server.js index 070c875189..b2bdf52d7a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,91 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +// backend/server.js +// force .env to override any OS env var +import dotenv from 'dotenv'; +dotenv.config({ override: true }); +import express from 'express'; +import cors from 'cors'; +import mongoose from 'mongoose'; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +import authRouter from './src/routes/auth.js'; +import secretRouter from './src/routes/secret.js'; +import readingsRouter from './src/routes/readings.js'; +import tarotRouter from './src/routes/tarot.js'; + +const PORT = process.env.PORT || 5000; +const MONGO_URI = process.env.MONGODB_URI; + +if (!MONGO_URI) { + console.error('❌ MONGODB_URI missing in backend/.env'); + process.exit(1); +} -const port = process.env.PORT || 8080; const app = express(); -app.use(cors()); +// --- CORS (dev + production) ----------------------------------------------- +const DEV_ORIGINS = [ + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:5175', + 'http://localhost:5176', +]; + +// Allow comma-separated env (ex: "https://pocketoracle.netlify.app,http://localhost:5173") +const ENV_ORIGINS = (process.env.CLIENT_ORIGIN || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + +const ALLOWED_ORIGINS = ENV_ORIGINS.length ? ENV_ORIGINS : DEV_ORIGINS; + +const corsOptions = { + origin(origin, cb) { + // allow curl/health checks (no Origin) and any listed origin + if (!origin || ALLOWED_ORIGINS.includes(origin)) return cb(null, true); + cb(new Error(`CORS: origin not allowed -> ${origin}`)); + }, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: false, + maxAge: 86400, + optionsSuccessStatus: 204 +}; + +app.use(cors(corsOptions)); +// Make sure every route responds to preflight +app.options('*', cors(corsOptions)); +// --------------------------------------------------------------------------- + app.use(express.json()); -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); +// Health check +app.get('/health', (_req, res) => res.json({ status: 'ok' })); + +// Routes +app.use('/api/auth', authRouter); +app.use('/api/secret', secretRouter); +app.use('/api/readings', readingsRouter); +app.use('/api/tarot', tarotRouter); -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); +// Demo root +app.get('/', (_req, res) => { + res.send('Hello Technigo!'); }); + +async function start() { + try { + await mongoose.connect(MONGO_URI); + console.log('✓ MongoDB connected'); + + app.listen(PORT, () => { + console.log(`✓ Server running on http://localhost:${PORT}`); + console.log('✓ CORS allowed origins:', (ALLOWED_ORIGINS || []).join(', ')); + }); + + } catch (err) { + console.error('DB connection failed:', err.message); + process.exit(1); + } +} + +start(); + diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000000..180f1e7b78 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,13 @@ +import jwt from 'jsonwebtoken'; + +export default function auth(req, res, next) { + const h = req.headers.authorization || ''; + const token = h.startsWith('Bearer ') ? h.slice(7) : null; + if (!token) return res.status(401).json({ error: 'Missing token' }); + try { + req.user = jwt.verify(token, process.env.JWT_SECRET); // { id, email, name } + next(); + } catch { + res.status(401).json({ error: 'Invalid token' }); + } +} diff --git a/backend/src/models/Reading.js b/backend/src/models/Reading.js new file mode 100644 index 0000000000..a311665251 --- /dev/null +++ b/backend/src/models/Reading.js @@ -0,0 +1,19 @@ +// backend/src/models/Reading.js +import mongoose from 'mongoose'; + +const CardSchema = new mongoose.Schema({ + id: { type: String, required: true }, // e.g., "XVI" or "The Tower" + reversed: { type: Boolean, default: false }, + position: { type: String }, // e.g., "Past", "Present", "Future" +}, { _id: false }); + +const ReadingSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true }, + spread: { type: String, enum: ['one', 'three', 'custom'], required: true }, + title: { type: String }, + notes: { type: String }, + tags: { type: [String], default: [] }, + cards: { type: [CardSchema], default: [] }, +}, { timestamps: true }); + +export default mongoose.model('Reading', ReadingSchema); diff --git a/backend/src/models/User.js b/backend/src/models/User.js new file mode 100644 index 0000000000..64c59c64cd --- /dev/null +++ b/backend/src/models/User.js @@ -0,0 +1,10 @@ +import mongoose from 'mongoose'; +const { Schema, model } = mongoose; + +const userSchema = new Schema({ + name: { type: String, required: true }, + email: { type: String, required: true, unique: true }, + passwordHash: { type: String, required: true } +}, { timestamps: true }); + +export default model('User', userSchema); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000000..145afa8b55 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import User from '../models/User.js'; +import auth from '../middleware/auth.js'; + +const router = Router(); + +router.post('/register', async (req, res) => { + const { name, email, password } = req.body || {}; + if (!name || !email || !password) return res.status(400).json({ error: 'Missing fields' }); + const exists = await User.findOne({ email }); + if (exists) return res.status(409).json({ error: 'Email already in use' }); + const passwordHash = await bcrypt.hash(password, 10); + await User.create({ name, email, passwordHash }); + res.status(201).json({ message: 'User created' }); +}); + +router.post('/login', async (req, res) => { + const { email, password } = req.body || {}; + const user = await User.findOne({ email }); + if (!user) return res.status(401).json({ error: 'Invalid credentials' }); + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) return res.status(401).json({ error: 'Invalid credentials' }); + const token = jwt.sign({ id: user._id, email: user.email, name: user.name }, process.env.JWT_SECRET, { expiresIn: '7d' }); + res.json({ token, user: { id: user._id, name: user.name, email: user.email } }); +}); + +router.get('/me', auth, (req, res) => res.json({ user: req.user })); + +export default router; diff --git a/backend/src/routes/readings.js b/backend/src/routes/readings.js new file mode 100644 index 0000000000..d0c4aef655 --- /dev/null +++ b/backend/src/routes/readings.js @@ -0,0 +1,131 @@ +// backend/src/routes/readings.js +import { Router } from 'express'; +import mongoose from 'mongoose'; +import auth from '../middleware/auth.js'; +import Reading from '../models/Reading.js'; + +const router = Router(); +const { isValidObjectId } = mongoose; + +// All routes below require JWT +router.use(auth); + +/** + * CREATE a reading + */ +router.post('/', async (req, res) => { + try { + const { spread, title, notes, tags = [], cards = [] } = req.body; + if (!spread) return res.status(400).json({ error: 'spread is required' }); + + const reading = await Reading.create({ + userId: req.user.id, + spread, + title, + notes, + tags, + cards, + }); + + res.status(201).json(reading); + } catch (e) { + res.status(400).json({ error: e.message || 'Invalid payload' }); + } +}); + +/** + * LIST your readings (paginated) + * GET /api/readings?page=1&limit=10 + */ +router.get('/', async (req, res) => { + try { + const page = Math.max(parseInt(req.query.page || '1', 10), 1); + const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10), 1), 50); + const skip = (page - 1) * limit; + + const [items, total] = await Promise.all([ + Reading.find({ userId: req.user.id }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), + Reading.countDocuments({ userId: req.user.id }), + ]); + + res.json({ + page, + limit, + total, + pages: Math.max(1, Math.ceil(total / limit)), + items, + }); + } catch (e) { + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * READ ONE (owner check) + */ +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + if (!isValidObjectId(id)) return res.status(400).json({ error: 'Bad id' }); + + const reading = await Reading.findById(id); + if (!reading || String(reading.userId) !== req.user.id) { + return res.status(404).json({ error: 'Not found' }); + } + + res.json(reading); + } catch (e) { + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * UPDATE (partial, owner check) + */ +router.patch('/:id', async (req, res) => { + try { + const { id } = req.params; + if (!isValidObjectId(id)) return res.status(400).json({ error: 'Bad id' }); + + const allowed = ['spread', 'title', 'notes', 'tags', 'cards']; + const updates = {}; + for (const k of allowed) if (k in req.body) updates[k] = req.body[k]; + + const reading = await Reading.findById(id); + if (!reading || String(reading.userId) !== req.user.id) { + return res.status(404).json({ error: 'Not found' }); + } + + Object.assign(reading, updates); + await reading.save(); + res.json(reading); + } catch (e) { + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * DELETE (owner check) + */ +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + if (!isValidObjectId(id)) return res.status(400).json({ error: 'Bad id' }); + + const reading = await Reading.findById(id); + if (!reading || String(reading.userId) !== req.user.id) { + return res.status(404).json({ error: 'Not found' }); + } + + await reading.deleteOne(); + res.json({ message: 'Deleted' }); + } catch (e) { + res.status(500).json({ error: 'Server error' }); + } +}); + +export default router; + diff --git a/backend/src/routes/secret.js b/backend/src/routes/secret.js new file mode 100644 index 0000000000..547afe6c4d --- /dev/null +++ b/backend/src/routes/secret.js @@ -0,0 +1,12 @@ +// backend/src/routes/secret.js +import { Router } from 'express'; +import auth from '../middleware/auth.js'; + +const router = Router(); + +// GET /api/secret (requires valid JWT) +router.get('/', auth, (req, res) => { + res.json({ message: `Hello ${req.user.name}, here’s your secret ✨` }); +}); + +export default router; diff --git a/backend/src/routes/tarot.js b/backend/src/routes/tarot.js new file mode 100644 index 0000000000..ebc74533ea --- /dev/null +++ b/backend/src/routes/tarot.js @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import axios from 'axios'; + +const router = Router(); +const BASE = process.env.TAROT_API_BASE || 'https://tarotapi.dev/api/v1'; + +router.get('/draw', async (req, res) => { + try { + const n = Number(req.query.n || 3); + const { data } = await axios.get(`${BASE}/cards/random`, { params: { n } }); + res.json(data); + } catch (e) { + res.status(502).json({ error: 'Tarot API failed', detail: e.message }); + } +}); + +router.get('/cards', async (_req, res) => { + try { + const { data } = await axios.get(`${BASE}/cards`); + res.json(data); + } catch (e) { + res.status(502).json({ error: 'Tarot API failed', detail: e.message }); + } +}); + +export default router; diff --git a/frontend/index.html b/frontend/index.html index 664410b5b9..fa44fe08aa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,46 @@ - - - - - Technigo React Vite Boiler Plate - - -
- - - + + + + + + + + + + + + Pocket Oracle + + + +
+ + + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..bede188fc3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^5.2.1", + "axios": "^1.11.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hook-form": "^7.62.0", + "react-router-dom": "^7.8.2", + "styled-components": "^6.1.19", + "zod": "^4.1.3" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000000..7797f7c6a7 --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/frontend/public/card-back.svg b/frontend/public/card-back.svg new file mode 100644 index 0000000000..8e7b8e9d08 --- /dev/null +++ b/frontend/public/card-back.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/hero.jpg b/frontend/public/hero.jpg new file mode 100644 index 0000000000..aa39ec4713 Binary files /dev/null and b/frontend/public/hero.jpg differ diff --git a/frontend/public/old-hero.jpg b/frontend/public/old-hero.jpg new file mode 100644 index 0000000000..7035000787 Binary files /dev/null and b/frontend/public/old-hero.jpg differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6e..d0f230ec84 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,91 @@ -export const App = () => { +// frontend/src/App.jsx +import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'; +import { useEffect } from 'react'; +import { AuthProvider, useAuth } from './context/AuthContext'; + +import Home from './pages/Home'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Secret from './pages/Secret'; + +import { ThemeProvider } from 'styled-components'; +import { getTheme } from './ui/theme'; +import { GlobalStyle } from './ui/GlobalStyle'; +import { Nav, NavLink, GhostButton, Brand } from './ui/components'; +import { useLocalStorage } from './hooks/useLocalStorage'; + +function Private({ children }) { + const { isLoggedIn } = useAuth(); + return isLoggedIn ? children : ; +} + +function TopNav({ mode, toggleMode }) { + const { isLoggedIn, logout } = useAuth(); + const navigate = useNavigate(); + + const onLogout = (e) => { + e.preventDefault(); + logout(); + navigate('/'); + }; return ( - <> -

Welcome to Final Project!

- + ); -}; +} + +export default function App() { + const [mode, setMode] = useLocalStorage('mode', 'light'); + const theme = getTheme(mode); + const toggleMode = () => setMode(mode === 'light' ? 'dark' : 'light'); + + // Warm the API (Render cold start) + useEffect(() => { + const url = `${import.meta.env.VITE_API_URL}/health`; + fetch(url, { cache: 'no-store' }).catch(() => { }); + }, []); + + return ( + + + + + + + } /> + } /> + } /> + } /> + Not found} /> + + + + + ); +} + + + diff --git a/frontend/src/Secret.jsx b/frontend/src/Secret.jsx new file mode 100644 index 0000000000..bc855139e6 --- /dev/null +++ b/frontend/src/Secret.jsx @@ -0,0 +1,57 @@ +// frontend/src/pages/Secret.jsx +import { useState } from 'react'; +import { Page, Grid, Card } from '../ui/components'; // keep your UI imports +import ReadingForm from '../components/ReadingForm'; +import ReadingsList from '../components/ReadingsList'; + +import { drawRandom } from '../services/tarot'; +import { imageUrlForCard } from '../lib/tarotImages'; + +export default function Secret() { + const [refreshKey, setRefreshKey] = useState(0); + const [cards, setCards] = useState([]); + + async function handleDraw() { + try { + const data = await drawRandom(3); + const list = Array.isArray(data) ? data : data.cards; + setCards(list || []); + } catch (e) { + alert(e?.response?.data?.error || e.message); + } + } + + return ( + + + +

Quick draw from API

+ +
+ {cards.map((c) => ( +
+ {c.name} { e.currentTarget.src = '/card-back.png'; }} + /> +
{c.name}
+ {c.meaning_up &&
{c.meaning_up}
} +
+ ))} +
+
+ + setRefreshKey(k => k + 1)} /> + +
+
+ ); +} + diff --git a/frontend/src/components/Hero.jsx b/frontend/src/components/Hero.jsx new file mode 100644 index 0000000000..f05e3690c1 --- /dev/null +++ b/frontend/src/components/Hero.jsx @@ -0,0 +1,26 @@ +// frontend/src/components/Hero.jsx +import { Link } from 'react-router-dom'; + +export default function Hero({ + title = 'Welcome to your pocket oracle!', + subtitle = 'Draw a card or login for more options.', + ctaLabel = 'More', + ctaTo = '/secret', + position = 'center 35%', // you control the crop via this prop +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} + {ctaLabel && {ctaLabel}} +
+
+ ); +} diff --git a/frontend/src/components/ReadingForm.jsx b/frontend/src/components/ReadingForm.jsx new file mode 100644 index 0000000000..e0426953ad --- /dev/null +++ b/frontend/src/components/ReadingForm.jsx @@ -0,0 +1,154 @@ +// frontend/src/components/ReadingForm.jsx +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { createReading } from '../services/readings'; +import { Card, H2, Field, Label, Input, Button, ErrorText, Hint } from '../ui/components'; + +const SpreadEnum = z.enum(['one', 'three']); + +const FormSchema = z.object({ + spread: SpreadEnum, + title: z.string().optional(), + notes: z.string().optional(), + tags: z.string().optional(), // comma separated, we'll split to [] + // one-card fields + oneCard: z.string().optional(), + oneRev: z.boolean().optional(), + // three-card fields + past: z.string().optional(), + pastRev: z.boolean().optional(), + present: z.string().optional(), + presentRev: z.boolean().optional(), + future: z.string().optional(), + futureRev: z.boolean().optional(), +}); + +export default function ReadingForm({ onCreated }) { + const [error, setError] = useState(''); + const { register, handleSubmit, watch, reset, formState: { errors, isSubmitting } } = + useForm({ resolver: zodResolver(FormSchema), defaultValues: { spread: 'three' } }); + + const spread = watch('spread'); + + const onSubmit = async (data) => { + setError(''); + + // Build payload for the API + const tags = (data.tags || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + + let payload = { + spread: data.spread, + title: data.title || '', + notes: data.notes || '', + tags, + cards: [], + }; + + if (data.spread === 'one') { + if (!data.oneCard) { setError('Please enter the card.'); return; } + payload.cards = [ + { id: data.oneCard, reversed: !!data.oneRev, position: 'Single' }, + ]; + } else { + if (!data.past || !data.present || !data.future) { + setError('Please fill in all three cards.'); + return; + } + payload.cards = [ + { id: data.past, reversed: !!data.pastRev, position: 'Past' }, + { id: data.present, reversed: !!data.presentRev, position: 'Present' }, + { id: data.future, reversed: !!data.futureRev, position: 'Future' }, + ]; + } + + try { + await createReading(payload); + reset({ spread: data.spread, title: '', notes: '', tags: '' }); + onCreated?.(); + } catch (e) { + setError(e?.response?.data?.error || e.message); + } + }; + + return ( + +

Create a Reading

+ Choose a spread, fill the cards, and save. Tags are optional (comma separated). + + {error && {error}} + +
+ + + + + + + + + {errors.title && {errors.title.message}} + + + + + + + + + + + + + {spread === 'one' ? ( + <> + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + + )} + + +
+
+ ); +} diff --git a/frontend/src/components/ReadingsList.jsx b/frontend/src/components/ReadingsList.jsx new file mode 100644 index 0000000000..fd7723d0e1 --- /dev/null +++ b/frontend/src/components/ReadingsList.jsx @@ -0,0 +1,129 @@ +// frontend/src/components/ReadingsList.jsx +import { useEffect, useState } from 'react'; +import { listReadings, deleteReading, updateReading } from '../services/readings'; + +export default function ReadingsList({ refreshKey = 0 }) { + const [page, setPage] = useState(1); + const [limit] = useState(10); // change to 5/20 if you prefer + const [data, setData] = useState({ items: [], total: 0, pages: 0, page: 1 }); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(''); + + // inline notes editing + const [editing, setEditing] = useState(null); // reading _id + const [draft, setDraft] = useState(''); + + useEffect(() => { + let ignore = false; + + (async () => { + setLoading(true); + try { + // listReadings should accept (page, limit) and return { page, pages, total, items } + const res = await listReadings(page, limit); + if (!ignore) { + setData(res); + setErr(''); + } + } catch (e) { + if (!ignore) setErr(e?.response?.data?.error || e.message); + } finally { + if (!ignore) setLoading(false); + } + })(); + + return () => { ignore = true; }; + }, [page, limit, refreshKey]); + + const onDelete = async (id) => { + if (!confirm('Delete this reading?')) return; + // If this was the last item on the page and not the first page, step back a page + const lastOnPage = data.items.length === 1 && page > 1; + await deleteReading(id); + if (lastOnPage) setPage(p => Math.max(1, p - 1)); + else { + // trigger a lightweight refresh without flipping page + setData(d => ({ ...d, items: d.items.filter(x => x._id !== id), total: d.total - 1 })); + } + }; + + const beginEdit = (r) => { setEditing(r._id); setDraft(r.notes || ''); }; + const cancelEdit = () => { setEditing(null); setDraft(''); }; + + const saveEdit = async (id) => { + await updateReading(id, { notes: draft }); + setEditing(null); + // update the item locally to avoid a full refetch + setData(d => ({ + ...d, + items: d.items.map(it => it._id === id ? { ...it, notes: draft } : it) + })); + }; + + const fmt = (iso) => new Date(iso).toLocaleString(); + + return ( + <> + {loading &&

Loading…

} + {err &&

{err}

} + + {!loading && !err && data.items.length === 0 && ( +

No readings yet. Draw a spread and save it to see it here.

+ )} + + {data.items.map(r => ( +
+

{r.title || '(untitled)'}

+

+ Spread: {r.spread} • Saved: {fmt(r.createdAt)} +

+ + {editing === r._id ? ( + <> +