From 8439741c4b69629e7b620b90224b7c4efc82569f Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Sun, 24 Aug 2025 19:14:31 +0200 Subject: [PATCH 01/29] Backend connected to Mongo + basic server running --- backend/package.json | 13 ++++++++---- backend/server.js | 50 +++++++++++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/backend/package.json b/backend/package.json index 08f29f2448..3b9df224eb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,14 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "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..7c459951ef 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,42 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +// backend/server.js +import 'dotenv/config'; // keep this FIRST +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; +const PORT = process.env.PORT || 5000; +const MONGO_URI = process.env.MONGODB_URI; // use MONGODB_URI (matches your .env) +const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173'; -const port = process.env.PORT || 8080; -const app = express(); +if (!MONGO_URI) { + console.error('❌ MONGODB_URI missing in backend/.env'); + process.exit(1); +} -app.use(cors()); +const app = express(); +app.use(cors({ origin: ORIGIN })); app.use(express.json()); -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); +// simple health check +app.get('/health', (_req, res) => res.json({ status: 'ok' })); -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); +// demo route (keep if you want) +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}`); + }); + } catch (err) { + console.error('DB connection failed:', err.message); + process.exit(1); + } +} + +start(); From ae29a5e1f3f6a034cd8c6602cc89fd00acdf506e Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Sun, 24 Aug 2025 21:00:08 +0200 Subject: [PATCH 02/29] Backend auth working (register/login/me) --- backend/server.js | 12 ++++++++---- backend/src/db.js | 0 backend/src/middleware/auth.js | 13 +++++++++++++ backend/src/models/User.js | 10 ++++++++++ backend/src/routes/auth.js | 31 +++++++++++++++++++++++++++++++ backend/src/routes/health.js | 0 6 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 backend/src/db.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/models/User.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/health.js diff --git a/backend/server.js b/backend/server.js index 7c459951ef..fb43517229 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,11 +1,12 @@ // backend/server.js -import 'dotenv/config'; // keep this FIRST +import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import mongoose from 'mongoose'; +import authRouter from './src/routes/auth.js'; // ← add const PORT = process.env.PORT || 5000; -const MONGO_URI = process.env.MONGODB_URI; // use MONGODB_URI (matches your .env) +const MONGO_URI = process.env.MONGODB_URI; const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173'; if (!MONGO_URI) { @@ -17,10 +18,13 @@ const app = express(); app.use(cors({ origin: ORIGIN })); app.use(express.json()); -// simple health check +// health (keep this, or replace with a healthRouter if you made one) app.get('/health', (_req, res) => res.json({ status: 'ok' })); -// demo route (keep if you want) +// mount auth routes +app.use('/api/auth', authRouter); + +// demo app.get('/', (_req, res) => { res.send('Hello Technigo!'); }); diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000000..e69de29bb2 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/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/health.js b/backend/src/routes/health.js new file mode 100644 index 0000000000..e69de29bb2 From 01888407864b9a6cb949ff8ba997db4a41de4be2 Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Mon, 25 Aug 2025 14:20:29 +0200 Subject: [PATCH 03/29] Frontend + backend wired; register/login working --- frontend/package.json | 7 +++++- frontend/src/App.jsx | 31 ++++++++++++++++++++---- frontend/src/context/AuthContext.jsx | 35 +++++++++++++++++++++++++++ frontend/src/hooks/useLocalStorage.js | 9 +++++++ frontend/src/main.jsx | 2 +- frontend/src/pages/Home.jsx | 17 +++++++++++++ frontend/src/pages/Login.jsx | 30 +++++++++++++++++++++++ frontend/src/pages/Register.jsx | 31 ++++++++++++++++++++++++ 8 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/hooks/useLocalStorage.js create mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Register.jsx diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..4d278ffe69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "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.1" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6e..726cb8b3f6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,29 @@ -export const App = () => { +import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import Home from './pages/Home'; +import Login from './pages/Login'; +import Register from './pages/Register'; +function Private({ children }) { + const { isLoggedIn } = useAuth(); + return isLoggedIn ? children : ; +} + +export default function App() { return ( - <> -

Welcome to Final Project!

- + + + + + } /> +
Top Secret Page
} /> + } /> + } /> + Not found} /> +
+
+
); -}; +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000000..1abe671989 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,35 @@ +import { createContext, useContext, useMemo } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [token, setToken] = useLocalStorage('token', null); + const [user, setUser] = useLocalStorage('user', null); + const api = import.meta.env.VITE_API_URL; + + const register = async (name, email, password) => { + const r = await fetch(`${api}/api/auth/register`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, password }) + }); + const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Register failed'); + return true; + }; + + const login = async (email, password) => { + const r = await fetch(`${api}/api/auth/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Login failed'); + setToken(d.token); setUser(d.user); + }; + + const logout = () => { setToken(null); setUser(null); }; + + const value = useMemo(() => ({ token, user, isLoggedIn: !!token, register, login, logout }), [token, user]); + return {children}; +} + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/hooks/useLocalStorage.js b/frontend/src/hooks/useLocalStorage.js new file mode 100644 index 0000000000..51b59a94fa --- /dev/null +++ b/frontend/src/hooks/useLocalStorage.js @@ -0,0 +1,9 @@ +import { useEffect, useState } from 'react'; +export function useLocalStorage(key, initialValue) { + const [value, setValue] = useState(() => { + const v = localStorage.getItem(key); + return v !== null ? JSON.parse(v) : initialValue; + }); + useEffect(() => { localStorage.setItem(key, JSON.stringify(value)); }, [key, value]); + return [value, setValue]; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f3998..b91620d35e 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; +import App from "./App.jsx"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")).render( diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000000..5141addd90 --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,17 @@ +import { useAuth } from '../context/AuthContext'; +export default function Home() { + const { user, isLoggedIn, logout } = useAuth(); + return ( +
+

Home

+ {isLoggedIn ? ( + <> +

Welcome, {user?.name}!

+ + + ) : ( +

You are not logged in.

+ )} +
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000000..377dd272a9 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,30 @@ +import { useForm } from 'react-hook-form'; +import { useAuth } from '../context/AuthContext'; +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; + +export default function Login() { + const { register: reg, handleSubmit } = useForm(); + const { login } = useAuth(); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const onSubmit = async (v) => { + setError(''); + try { await login(v.email, v.password); navigate('/'); } + catch (e) { setError(e.message); } + }; + + return ( +
+

Login

+ {error &&

{error}

} +
+
+
+ +
+

New here? Create an account

+
+ ); +} diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx new file mode 100644 index 0000000000..070a7c36b2 --- /dev/null +++ b/frontend/src/pages/Register.jsx @@ -0,0 +1,31 @@ +import { useForm } from 'react-hook-form'; +import { useAuth } from '../context/AuthContext'; +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; + +export default function Register() { + const { register: reg, handleSubmit } = useForm(); + const { register: doRegister } = useAuth(); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const onSubmit = async ({ name, email, password }) => { + setError(''); + try { await doRegister(name, email, password); navigate('/login'); } + catch (e) { setError(e.message); } + }; + + return ( +
+

Register

+ {error &&

{error}

} +
+
+
+
+ +
+

Already have an account? Login

+
+ ); +} From f47cea1731aaa4403655bcb90352808316f55e62 Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Mon, 25 Aug 2025 18:29:36 +0200 Subject: [PATCH 04/29] Add new files --- backend/server.js | 4 +++- backend/src/routes/secret.js | 12 ++++++++++++ frontend/src/Secret.jsx | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/secret.js create mode 100644 frontend/src/Secret.jsx diff --git a/backend/server.js b/backend/server.js index fb43517229..1ac2f0af5f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,7 +3,8 @@ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import mongoose from 'mongoose'; -import authRouter from './src/routes/auth.js'; // ← add +import authRouter from './src/routes/auth.js'; +import secretRouter from './src/routes/secret.js'; const PORT = process.env.PORT || 5000; const MONGO_URI = process.env.MONGODB_URI; @@ -17,6 +18,7 @@ if (!MONGO_URI) { const app = express(); app.use(cors({ origin: ORIGIN })); app.use(express.json()); +app.use('/api/secret', secretRouter); // health (keep this, or replace with a healthRouter if you made one) app.get('/health', (_req, res) => res.json({ status: 'ok' })); 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/frontend/src/Secret.jsx b/frontend/src/Secret.jsx new file mode 100644 index 0000000000..95627c551a --- /dev/null +++ b/frontend/src/Secret.jsx @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; +import { useAuth } from '../context/AuthContext'; + +export default function Secret() { + const { token, user } = useAuth(); + const api = import.meta.env.VITE_API_URL; + const [msg, setMsg] = useState(''); + const [err, setErr] = useState(''); + + useEffect(() => { + if (!token) return; + fetch(`${api}/api/secret`, { headers: { Authorization: `Bearer ${token}` } }) + .then(async (r) => { + const data = await r.json().catch(() => ({})); + if (!r.ok) From d70771a8c435e89a2ead596b58d59bf69d976828 Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Tue, 26 Aug 2025 12:36:31 +0200 Subject: [PATCH 05/29] Add Zod validation to Register & Login --- frontend/package.json | 3 +- frontend/src/App.jsx | 3 +- frontend/src/Secret.jsx | 39 +++++++++++++++--- frontend/src/pages/Login.jsx | 55 +++++++++++++++++++------ frontend/src/pages/Register.jsx | 73 +++++++++++++++++++++++++++------ frontend/src/pages/Secret.jsx | 33 +++++++++++++++ 6 files changed, 174 insertions(+), 32 deletions(-) create mode 100644 frontend/src/pages/Secret.jsx diff --git a/frontend/package.json b/frontend/package.json index 4d278ffe69..bede188fc3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,13 +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-hook-form": "^7.62.0", "react-router-dom": "^7.8.2", "styled-components": "^6.1.19", - "zod": "^4.1.1" + "zod": "^4.1.3" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 726cb8b3f6..e71cba9fd0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ 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'; function Private({ children }) { const { isLoggedIn } = useAuth(); @@ -18,7 +19,7 @@ export default function App() { } /> -
Top Secret Page
} /> + } /> } /> } /> Not found} /> diff --git a/frontend/src/Secret.jsx b/frontend/src/Secret.jsx index 95627c551a..7718ae7918 100644 --- a/frontend/src/Secret.jsx +++ b/frontend/src/Secret.jsx @@ -1,3 +1,4 @@ +// frontend/src/pages/Secret.jsx import { useEffect, useState } from 'react'; import { useAuth } from '../context/AuthContext'; @@ -8,8 +9,36 @@ export default function Secret() { const [err, setErr] = useState(''); useEffect(() => { - if (!token) return; - fetch(`${api}/api/secret`, { headers: { Authorization: `Bearer ${token}` } }) - .then(async (r) => { - const data = await r.json().catch(() => ({})); - if (!r.ok) + if (!token) { + setErr('You must be logged in.'); + return; + } + + let cancelled = false; + + (async () => { + try { + const res = await fetch(`${api}/api/secret`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + if (!cancelled) setMsg(data.message); + } catch (e) { + if (!cancelled) setErr(e.message || 'Failed to load secret'); + } + })(); + + return () => { cancelled = true; }; + }, [api, token]); + + return ( +
+

Secret API

+

User: {user?.name}

+ + {msg &&

{msg}

} + {err &&

{err}

} +
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 377dd272a9..ee18b760f1 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,30 +1,61 @@ import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; import { useAuth } from '../context/AuthContext'; import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; +const schema = z.object({ + email: z.string().email('Enter a valid email address'), + password: z.string().min(6, 'Password must be at least 6 characters'), +}); + export default function Login() { - const { register: reg, handleSubmit } = useForm(); const { login } = useAuth(); - const [error, setError] = useState(''); + const [serverError, setServerError] = useState(''); const navigate = useNavigate(); + const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ + resolver: zodResolver(schema), + mode: 'onTouched', + }); + const onSubmit = async (v) => { - setError(''); - try { await login(v.email, v.password); navigate('/'); } - catch (e) { setError(e.message); } + setServerError(''); + try { + await login(v.email, v.password); + navigate('/'); + } catch (e) { + setServerError(e.message || 'Login failed'); + } }; return ( -
+

Login

- {error &&

{error}

} -
-
-
- + {serverError &&

{serverError}

} + + + + {errors.email && } + + + {errors.password && } + +
-

New here? Create an account

+ +

+ New here? Create an account +

); } diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 070a7c36b2..e2b011ef4d 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -1,31 +1,78 @@ import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; import { useAuth } from '../context/AuthContext'; import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; +const schema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.string().email('Enter a valid email address'), + password: z.string().min(6, 'Password must be at least 6 characters'), + confirmPassword: z.string().min(6, 'Confirm your password'), +}).refine(v => v.password === v.confirmPassword, { + message: 'Passwords must match', + path: ['confirmPassword'], +}); + export default function Register() { - const { register: reg, handleSubmit } = useForm(); const { register: doRegister } = useAuth(); - const [error, setError] = useState(''); + const [serverError, setServerError] = useState(''); const navigate = useNavigate(); + const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ + resolver: zodResolver(schema), + mode: 'onTouched', + }); + const onSubmit = async ({ name, email, password }) => { - setError(''); - try { await doRegister(name, email, password); navigate('/login'); } - catch (e) { setError(e.message); } + setServerError(''); + try { + await doRegister(name, email, password); + navigate('/login'); + } catch (e) { + setServerError(e.message || 'Register failed'); + } }; return ( -
+

Register

- {error &&

{error}

} -
-
-
-
- + {serverError &&

{serverError}

} + + + + {errors.name && } + + + {errors.email && } + + + {errors.password && } + + + {errors.confirmPassword && } + +
-

Already have an account? Login

+ +

+ Already have an account? Login +

); } diff --git a/frontend/src/pages/Secret.jsx b/frontend/src/pages/Secret.jsx new file mode 100644 index 0000000000..9e19520e2c --- /dev/null +++ b/frontend/src/pages/Secret.jsx @@ -0,0 +1,33 @@ +// frontend/src/pages/Secret.jsx +import { useEffect, useState } from 'react'; +import { useAuth } from '../context/AuthContext'; + +export default function Secret() { + const { token, user } = useAuth(); + const api = import.meta.env.VITE_API_URL; + const [msg, setMsg] = useState(''); + const [err, setErr] = useState(''); + + useEffect(() => { + if (!token) return setErr('You must be logged in.'); + (async () => { + try { + const res = await fetch(`${api}/api/secret`, { headers: { Authorization: `Bearer ${token}` } }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + setMsg(data.message); + } catch (e) { + setErr(e.message || 'Failed to load secret'); + } + })(); + }, [api, token]); + + return ( +
+

Secret API

+

User: {user?.name}

+ {msg &&

{msg}

} + {err &&

{err}

} +
+ ); +} From d247578e93a127c860d1a57d6ffbe57ca9a79023 Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Tue, 26 Aug 2025 13:24:07 +0200 Subject: [PATCH 06/29] Axios helper added; auth context uses auto Bearer token --- frontend/src/context/AuthContext.jsx | 37 ++++++++++++++++------------ frontend/src/lib/api.js | 30 ++++++++++++++++++++++ frontend/src/pages/Secret.jsx | 15 +++++------ 3 files changed, 57 insertions(+), 25 deletions(-) create mode 100644 frontend/src/lib/api.js diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 1abe671989..3ef44d628f 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,34 +1,39 @@ -import { createContext, useContext, useMemo } from 'react'; +import { createContext, useContext, useMemo, useEffect } from 'react'; import { useLocalStorage } from '../hooks/useLocalStorage'; +import api, { setAuthToken } from '../lib/api'; const AuthContext = createContext(null); export function AuthProvider({ children }) { const [token, setToken] = useLocalStorage('token', null); const [user, setUser] = useLocalStorage('user', null); - const api = import.meta.env.VITE_API_URL; + + // Whenever token changes (or on page refresh), apply/remove it on axios + useEffect(() => { setAuthToken(token); }, [token]); const register = async (name, email, password) => { - const r = await fetch(`${api}/api/auth/register`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, email, password }) - }); - const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Register failed'); - return true; + const { data } = await api.post('/api/auth/register', { name, email, password }); + // server returns { message: 'User created' } + return data?.message === 'User created'; }; const login = async (email, password) => { - const r = await fetch(`${api}/api/auth/login`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }) - }); - const d = await r.json(); if (!r.ok) throw new Error(d.error || 'Login failed'); - setToken(d.token); setUser(d.user); + const { data } = await api.post('/api/auth/login', { email, password }); + // data = { token, user } + setToken(data.token); + setUser(data.user); + }; + + const logout = () => { + setToken(null); + setUser(null); + setAuthToken(null); // remove Authorization header from axios }; - const logout = () => { setToken(null); setUser(null); }; + const value = useMemo(() => ({ + token, user, isLoggedIn: !!token, register, login, logout + }), [token, user]); - const value = useMemo(() => ({ token, user, isLoggedIn: !!token, register, login, logout }), [token, user]); return {children}; } diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000000..c729a6441e --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,30 @@ +// frontend/src/lib/api.js +import axios from 'axios'; + +// One axios instance for your whole app +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, // e.g. http://localhost:5000 + // timeout: 10000, // (optional) +}); + +// Attach/remove the JWT to all requests made with this instance +export function setAuthToken(token) { + if (token) { + api.defaults.headers.common.Authorization = `Bearer ${token}`; + } else { + delete api.defaults.headers.common.Authorization; + } +} + +// (optional) Handle 401s globally +// api.interceptors.response.use( +// (res) => res, +// (err) => { +// if (err.response?.status === 401) { +// // e.g. redirect to /login or clear local state +// } +// return Promise.reject(err); +// } +// ); + +export default api; diff --git a/frontend/src/pages/Secret.jsx b/frontend/src/pages/Secret.jsx index 9e19520e2c..34500b6328 100644 --- a/frontend/src/pages/Secret.jsx +++ b/frontend/src/pages/Secret.jsx @@ -1,26 +1,23 @@ -// frontend/src/pages/Secret.jsx import { useEffect, useState } from 'react'; +import api from '../lib/api'; import { useAuth } from '../context/AuthContext'; export default function Secret() { - const { token, user } = useAuth(); - const api = import.meta.env.VITE_API_URL; + const { user } = useAuth(); const [msg, setMsg] = useState(''); const [err, setErr] = useState(''); useEffect(() => { - if (!token) return setErr('You must be logged in.'); (async () => { try { - const res = await fetch(`${api}/api/secret`, { headers: { Authorization: `Bearer ${token}` } }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + const { data } = await api.get('/api/secret'); // token auto-attached setMsg(data.message); } catch (e) { - setErr(e.message || 'Failed to load secret'); + const msg = e.response?.data?.error || e.message || 'Failed to load'; + setErr(msg); } })(); - }, [api, token]); + }, []); return (
From 9178ffbc00d812712335432a3fa9779cbde16ce1 Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Tue, 26 Aug 2025 14:04:29 +0200 Subject: [PATCH 07/29] Home facelift + light/dark mode toggle --- frontend/src/App.jsx | 51 ++++++++++++++------ frontend/src/pages/Home.jsx | 55 ++++++++++++++++----- frontend/src/pages/Login.jsx | 67 +++++++++++++------------- frontend/src/pages/Register.jsx | 85 ++++++++++++++++----------------- frontend/src/ui/GlobalStyle.js | 18 +++++++ frontend/src/ui/components.js | 73 ++++++++++++++++++++++++++++ frontend/src/ui/theme.js | 38 +++++++++++++++ 7 files changed, 283 insertions(+), 104 deletions(-) create mode 100644 frontend/src/ui/GlobalStyle.js create mode 100644 frontend/src/ui/components.js create mode 100644 frontend/src/ui/theme.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e71cba9fd0..e9937e7088 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,30 +1,51 @@ -import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; 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, NavGroup, NavLink, GhostButton } from './ui/components'; +import { useLocalStorage } from './hooks/useLocalStorage'; + function Private({ children }) { const { isLoggedIn } = useAuth(); return isLoggedIn ? children : ; } export default function App() { + const [mode, setMode] = useLocalStorage('mode', 'light'); + const theme = getTheme(mode); + const toggleMode = () => setMode(mode === 'light' ? 'dark' : 'light'); + return ( - - - - - } /> - } /> - } /> - } /> - Not found} /> - - - + + + + + + + } /> + } /> + } /> + } /> + Not found} /> + + + + ); } diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 5141addd90..7f5c857c54 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -1,17 +1,50 @@ import { useAuth } from '../context/AuthContext'; +import { Page, Card, H1, H2, Hint, Button, Grid } from '../ui/components'; +import { Link } from 'react-router-dom'; + export default function Home() { const { user, isLoggedIn, logout } = useAuth(); + return ( -
-

Home

- {isLoggedIn ? ( - <> -

Welcome, {user?.name}!

- - - ) : ( -

You are not logged in.

- )} -
+ + + +

{isLoggedIn ? `Welcome, ${user?.name}!` : 'Welcome to the Tarot App'}

+ + {isLoggedIn + ? 'You are logged in. Head to your secret dashboard or start exploring features.' + : 'Create an account or log in to save readings, daily cards, and notes.'} + + + {isLoggedIn ? ( + <> + + + + ) : ( + <> + + + + )} +
+ + +

What’s next?

+
    +
  • Daily Card (per-user) with history
  • +
  • Readings (1–3 card spreads) with notes
  • +
  • Saved spreads and journal
  • +
+ + The “Secret” link is your temporary dashboard—JWT-protected and ready to expand. + +
+
+
); } diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index ee18b760f1..23c8b33435 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useAuth } from '../context/AuthContext'; import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; +import { Page, Card, H1, Hint, Field, Label, Input, ErrorText, Button } from '../ui/components'; const schema = z.object({ email: z.string().email('Enter a valid email address'), @@ -16,46 +17,44 @@ export default function Login() { const navigate = useNavigate(); const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ - resolver: zodResolver(schema), - mode: 'onTouched', + resolver: zodResolver(schema), mode: 'onTouched', }); const onSubmit = async (v) => { setServerError(''); - try { - await login(v.email, v.password); - navigate('/'); - } catch (e) { - setServerError(e.message || 'Login failed'); - } + try { await login(v.email, v.password); navigate('/'); } + catch (e) { setServerError(e.message || 'Login failed'); } }; return ( -
-

Login

- {serverError &&

{serverError}

} - -
- - {errors.email && } - - - {errors.password && } - - -
- -

- New here? Create an account -

-
+ + +

Login

+ Welcome back! Please sign in to continue. + {serverError && {serverError}} + +
+ + + + {errors.email && {errors.email.message}} + + + + + + {errors.password && {errors.password.message}} + + + +
+ + + New here? Create an account + +
+
); } diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index e2b011ef4d..750c1f8f79 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -4,16 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useAuth } from '../context/AuthContext'; import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; +import { Page, Card, H1, Hint, Field, Label, Input, ErrorText, Button } from '../ui/components'; const schema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Enter a valid email address'), password: z.string().min(6, 'Password must be at least 6 characters'), confirmPassword: z.string().min(6, 'Confirm your password'), -}).refine(v => v.password === v.confirmPassword, { - message: 'Passwords must match', - path: ['confirmPassword'], -}); +}).refine(v => v.password === v.confirmPassword, { message: 'Passwords must match', path: ['confirmPassword'] }); export default function Register() { const { register: doRegister } = useAuth(); @@ -21,58 +19,57 @@ export default function Register() { const navigate = useNavigate(); const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ - resolver: zodResolver(schema), - mode: 'onTouched', + resolver: zodResolver(schema), mode: 'onTouched', }); const onSubmit = async ({ name, email, password }) => { setServerError(''); - try { - await doRegister(name, email, password); - navigate('/login'); - } catch (e) { - setServerError(e.message || 'Register failed'); - } + try { await doRegister(name, email, password); navigate('/login'); } + catch (e) { setServerError(e.message || 'Register failed'); } }; return ( -
-

Register

- {serverError &&

{serverError}

} + + +

Create account

+ Use a valid email and a password with at least 6 characters. + {serverError && {serverError}} -
- - {errors.name && } + + + + + {errors.name && {errors.name.message}} + - - {errors.email && } + + + + {errors.email && {errors.email.message}} + - - {errors.password && } + + + + {errors.password && {errors.password.message}} + - - {errors.confirmPassword && } + + + + {errors.confirmPassword && {errors.confirmPassword.message}} + - -
+ + -

- Already have an account? Login -

-
+ + Already have an account? Login + + + ); } + diff --git a/frontend/src/ui/GlobalStyle.js b/frontend/src/ui/GlobalStyle.js new file mode 100644 index 0000000000..d9dcc37f9c --- /dev/null +++ b/frontend/src/ui/GlobalStyle.js @@ -0,0 +1,18 @@ +// frontend/src/ui/GlobalStyle.js +import { createGlobalStyle } from 'styled-components'; + +export const GlobalStyle = createGlobalStyle` + *,*::before,*::after{ box-sizing:border-box; } + html,body,#root{ height:100%; } + body{ + margin:0; background: ${({ theme }) => theme.colors.page}; + color:${({ theme }) => theme.colors.text}; + font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + } + a{ color:inherit; text-decoration:none; } + :focus-visible{ outline:3px solid ${({ theme }) => theme.colors.focus}; outline-offset:2px; } + @media (prefers-reduced-motion: reduce){ + *,*::before,*::after{ animation-duration:0.01ms !important; animation-iteration-count:1 !important; transition-duration:0.01ms !important; scroll-behavior:auto !important; } + } +`; diff --git a/frontend/src/ui/components.js b/frontend/src/ui/components.js new file mode 100644 index 0000000000..fccf00033c --- /dev/null +++ b/frontend/src/ui/components.js @@ -0,0 +1,73 @@ +// frontend/src/ui/components.js +import styled from 'styled-components'; +import { Link as RouterLink } from 'react-router-dom'; + +export const Page = styled.main` + max-width: 960px; + margin: 0 auto; + padding: clamp(16px, 3vw, 32px); +`; + +export const Card = styled.section` + background:${({ theme }) => theme.colors.bg}; + border:1px solid ${({ theme }) => theme.colors.border}; + border-radius:${({ theme }) => theme.radii.lg}; + box-shadow:${({ theme }) => theme.shadow.sm}; + padding: clamp(16px, 3vw, 28px); +`; + +export const H1 = styled.h1`margin: 0 0 12px; font-weight: 700;`; +export const H2 = styled.h2`margin: 0 0 8px; font-weight: 700; font-size:1.25rem;`; +export const Hint = styled.p`margin: 8px 0 16px; color:${({ theme }) => theme.colors.muted};`; + +export const Field = styled.div`margin: 12px 0;`; +export const Label = styled.label`display:block; margin: 0 0 6px; font-weight:600;`; +export const Input = styled.input` + width:100%; padding:12px 14px; border-radius:${({ theme }) => theme.radii.md}; + border:1px solid ${({ theme }) => theme.colors.border}; background:#fff; + &:focus{ border-color:${({ theme }) => theme.colors.focus}; box-shadow:0 0 0 4px rgba(37,99,235,.15); } + &[aria-invalid="true"]{ border-color:${({ theme }) => theme.colors.danger}; } +`; + +export const ErrorText = styled.p` + margin:6px 0 0; color:${({ theme }) => theme.colors.danger}; font-size:0.925rem; +`; + +export const Button = styled.button` + display:inline-flex; align-items:center; justify-content:center; gap:8px; + padding:12px 16px; border:0; border-radius:${({ theme }) => theme.radii.md}; + color:${({ theme }) => theme.colors.primaryText}; background:${({ theme }) => theme.colors.primary}; + cursor:pointer; font-weight:600; box-shadow:${({ theme }) => theme.shadow.sm}; + &:hover{ background:${({ theme }) => theme.colors.primaryHover}; } + &:disabled{ opacity:.6; cursor:not-allowed; } +`; + +export const GhostButton = styled(Button)` + background: transparent; color: ${({ theme }) => theme.colors.text}; + border:1px solid ${({ theme }) => theme.colors.border}; + &:hover{ background: ${({ theme }) => theme.colors.page}; } +`; + +export const Nav = styled.nav` + background:${({ theme }) => theme.colors.bg}; + border-bottom:1px solid ${({ theme }) => theme.colors.border}; + padding: 10px 16px; margin-bottom: 16px; + position: sticky; top: 0; z-index: 1; + + display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; + gap: 8px; +`; + +export const NavGroup = styled.div` + display:flex; align-items:center; gap:8px; flex-wrap:wrap; +`; + +export const NavLink = styled(RouterLink)` + padding: 6px 10px; border-radius:${({ theme }) => theme.radii.sm}; + &:hover{ background:${({ theme }) => theme.colors.page}; } +`; + +export const Grid = styled.div` + display:grid; gap:16px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +`; diff --git a/frontend/src/ui/theme.js b/frontend/src/ui/theme.js new file mode 100644 index 0000000000..33c2ed2fb9 --- /dev/null +++ b/frontend/src/ui/theme.js @@ -0,0 +1,38 @@ +// frontend/src/ui/theme.js +export const light = { + colors: { + bg: '#ffffff', + page: '#f8fafc', + text: '#111827', + muted: '#6b7280', + border: '#e5e7eb', + primary: '#7c3aed', + primaryHover: '#6d28d9', + primaryText: '#ffffff', + danger: '#dc2626', + focus: '#2563eb', + }, + radii: { sm: '8px', md: '12px', lg: '16px' }, + shadow: { sm: '0 1px 2px rgba(0,0,0,.06)', md: '0 6px 20px rgba(0,0,0,.08)' }, + space: (n) => `${n * 4}px`, +}; + +export const dark = { + colors: { + bg: '#0b1020', + page: '#070b16', + text: '#e5e7eb', + muted: '#9ca3af', + border: '#1f2937', + primary: '#8b5cf6', + primaryHover: '#7c3aed', + primaryText: '#ffffff', + danger: '#ef4444', + focus: '#93c5fd', + }, + radii: { sm: '8px', md: '12px', lg: '16px' }, + shadow: { sm: '0 1px 2px rgba(0,0,0,.25)', md: '0 6px 20px rgba(0,0,0,.35)' }, + space: (n) => `${n * 4}px`, +}; + +export const getTheme = (mode = 'light') => (mode === 'dark' ? dark : light); From c02360a14d46db810c2b72ad410d91eb9400df42 Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Wed, 27 Aug 2025 17:59:59 +0200 Subject: [PATCH 08/29] modified: backend/server.js, modified: frontend/src/pages/Secret.jsx --- .gitignore | 9 +- backend/server.js | 35 ++++-- backend/src/models/Reading.js | 19 +++ backend/src/routes/readings.js | 89 +++++++++++++ frontend/src/components/ReadingForm.jsx | 154 +++++++++++++++++++++++ frontend/src/components/ReadingsList.jsx | 61 +++++++++ frontend/src/pages/Secret.jsx | 37 ++---- frontend/src/services/readings.js | 16 +++ 8 files changed, 387 insertions(+), 33 deletions(-) create mode 100644 backend/src/models/Reading.js create mode 100644 backend/src/routes/readings.js create mode 100644 frontend/src/components/ReadingForm.jsx create mode 100644 frontend/src/components/ReadingsList.jsx create mode 100644 frontend/src/services/readings.js 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/backend/server.js b/backend/server.js index 1ac2f0af5f..7d331d7946 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,14 +1,17 @@ // backend/server.js -import 'dotenv/config'; +// 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'; + import authRouter from './src/routes/auth.js'; import secretRouter from './src/routes/secret.js'; +import readingsRouter from './src/routes/readings.js'; const PORT = process.env.PORT || 5000; const MONGO_URI = process.env.MONGODB_URI; -const ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173'; if (!MONGO_URI) { console.error('❌ MONGODB_URI missing in backend/.env'); @@ -16,17 +19,33 @@ if (!MONGO_URI) { } const app = express(); -app.use(cors({ origin: ORIGIN })); + +// --- CORS: allow multiple origins in dev, or use CLIENT_ORIGIN env (comma-separated) --- +const DEV_ORIGINS = [ + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:5175', + 'http://localhost:5176', +]; + +const ORIGINS = process.env.CLIENT_ORIGIN + ? process.env.CLIENT_ORIGIN.split(',').map(s => s.trim()).filter(Boolean) + : DEV_ORIGINS; + +app.use(cors({ origin: ORIGINS })); +// --------------------------------------------------------------------------- + app.use(express.json()); -app.use('/api/secret', secretRouter); -// health (keep this, or replace with a healthRouter if you made one) +// Health check app.get('/health', (_req, res) => res.json({ status: 'ok' })); -// mount auth routes +// Routes app.use('/api/auth', authRouter); +app.use('/api/secret', secretRouter); +app.use('/api/readings', readingsRouter); -// demo +// Demo root app.get('/', (_req, res) => { res.send('Hello Technigo!'); }); @@ -38,6 +57,7 @@ async function start() { app.listen(PORT, () => { console.log(`✓ Server running on http://localhost:${PORT}`); + console.log('✓ CORS allowed origins:', ORIGINS.join(', ')); }); } catch (err) { console.error('DB connection failed:', err.message); @@ -46,3 +66,4 @@ async function start() { } start(); + 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/routes/readings.js b/backend/src/routes/readings.js new file mode 100644 index 0000000000..a2750fa237 --- /dev/null +++ b/backend/src/routes/readings.js @@ -0,0 +1,89 @@ +// backend/src/routes/readings.js +import { Router } from 'express'; +import auth from '../middleware/auth.js'; +import Reading from '../models/Reading.js'; + +const router = Router(); + +// All routes below require JWT +router.use(auth); + +// CREATE +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 }); + } +}); + +// LIST (own), with simple pagination +router.get('/', async (req, res) => { + 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.ceil(total / limit), + items, + }); +}); + +// READ ONE (owner check) +router.get('/:id', async (req, res) => { + const reading = await Reading.findById(req.params.id); + if (!reading || String(reading.userId) !== req.user.id) { + return res.status(404).json({ error: 'Not found' }); + } + res.json(reading); +}); + +// UPDATE (owner check) — partial +router.patch('/:id', async (req, res) => { + 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(req.params.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); +}); + +// DELETE (owner check) +router.delete('/:id', async (req, res) => { + const reading = await Reading.findById(req.params.id); + if (!reading || String(reading.userId) !== req.user.id) { + return res.status(404).json({ error: 'Not found' }); + } + await reading.deleteOne(); + res.json({ message: 'Deleted' }); +}); + +export default router; 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..16bf4ba428 --- /dev/null +++ b/frontend/src/components/ReadingsList.jsx @@ -0,0 +1,61 @@ +// frontend/src/components/ReadingsList.jsx +import { useEffect, useState } from 'react'; +import { listReadings, deleteReading } from '../services/readings'; +import { Card, H2, Grid, Button, Hint } from '../ui/components'; + +export default function ReadingsList({ refreshKey = 0 }) { + const [data, setData] = useState({ items: [], total: 0 }); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(''); + + const load = async () => { + try { + setLoading(true); + const res = await listReadings(); + setData(res); + setErr(''); + } catch (e) { + setErr(e?.response?.data?.error || e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [refreshKey]); + + const onDelete = async (id) => { + if (!confirm('Delete this reading?')) return; + try { + await deleteReading(id); + await load(); + } catch (e) { + alert(e?.response?.data?.error || e.message); + } + }; + + return ( + +

My Readings

+ Total: {data.total} + {loading &&

Loading…

} + {err &&

{err}

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

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

+

Spread: {r.spread}

+ {r.notes &&

{r.notes}

} + {r.tags?.length > 0 &&

#{r.tags.join(' #')}

} +
    + {r.cards.map((c, i) => ( +
  • {c.position}: {c.id}{c.reversed ? ' (reversed)' : ''}
  • + ))} +
+ +
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Secret.jsx b/frontend/src/pages/Secret.jsx index 34500b6328..18a9f9265e 100644 --- a/frontend/src/pages/Secret.jsx +++ b/frontend/src/pages/Secret.jsx @@ -1,30 +1,17 @@ -import { useEffect, useState } from 'react'; -import api from '../lib/api'; -import { useAuth } from '../context/AuthContext'; +// frontend/src/pages/Secret.jsx +import { useState } from 'react'; +import { Page, Grid } from '../ui/components'; +import ReadingForm from '../components/ReadingForm'; +import ReadingsList from '../components/ReadingsList'; export default function Secret() { - const { user } = useAuth(); - const [msg, setMsg] = useState(''); - const [err, setErr] = useState(''); - - useEffect(() => { - (async () => { - try { - const { data } = await api.get('/api/secret'); // token auto-attached - setMsg(data.message); - } catch (e) { - const msg = e.response?.data?.error || e.message || 'Failed to load'; - setErr(msg); - } - })(); - }, []); - + const [refreshKey, setRefreshKey] = useState(0); return ( -
-

Secret API

-

User: {user?.name}

- {msg &&

{msg}

} - {err &&

{err}

} -
+ + + setRefreshKey(k => k + 1)} /> + + + ); } diff --git a/frontend/src/services/readings.js b/frontend/src/services/readings.js new file mode 100644 index 0000000000..46adf22580 --- /dev/null +++ b/frontend/src/services/readings.js @@ -0,0 +1,16 @@ +import api from '../lib/api'; + +export const listReadings = (page = 1, limit = 10) => + api.get('/api/readings', { params: { page, limit } }).then(r => r.data); + +export const createReading = (payload) => + api.post('/api/readings', payload).then(r => r.data); + +export const getReading = (id) => + api.get(`/api/readings/${id}`).then(r => r.data); + +export const updateReading = (id, payload) => + api.patch(`/api/readings/${id}`, payload).then(r => r.data); + +export const deleteReading = (id) => + api.delete(`/api/readings/${id}`).then(r => r.data); From a5555dd50780b811b8bd03d6f98b84f7cfec2cad Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Wed, 27 Aug 2025 21:49:50 +0200 Subject: [PATCH 09/29] Card images showing --- README.md | 28 +++++++++++- backend/package.json | 1 + backend/server.js | 2 + backend/src/routes/tarot.js | 26 +++++++++++ frontend/public/card-back.svg | 12 +++++ frontend/src/Secret.jsx | 81 +++++++++++++++++++-------------- frontend/src/lib/tarotImages.js | 67 +++++++++++++++++++++++++++ frontend/src/pages/Secret.jsx | 44 +++++++++++++++++- frontend/src/services/tarot.js | 12 +++++ 9 files changed, 237 insertions(+), 36 deletions(-) create mode 100644 backend/src/routes/tarot.js create mode 100644 frontend/public/card-back.svg create mode 100644 frontend/src/lib/tarotImages.js create mode 100644 frontend/src/services/tarot.js diff --git a/README.md b/README.md index 31466b54c2..ae0b3be033 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,34 @@ # Final Project Replace this readme with your own information about your project. +A Tarot-app to get guidence in your exictens on earth. Focus on a three card. First card representing a focal point to work on, second card representing the help you will get from the world and third card representing your gift back to the world when you worked on the focal point with help from the world. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +Project: Final Project +👾 Technical Requirements: + +Frontend: React +Backend: Node.js with Express +Database: MongoDB +Must include: + +Authentication +Navigation using React Router +Global state management (Context API, Zustand or equivalent) +At least two external libraries beyond those listed above +A React hook that was not part of the taught curriculum +Support for Chrome, Firefox, and Safari +Fully responsive design for devices between at least 320px–1600px +Follows accessibility standards and 100% score in Lighthouse +Follows Clean Code practices +🎨 Visual Requirements: + +Clear structure using the box model with consistent margins and paddings +Consistent h1–h6 typography across views and breakpoints +Cohesive colour scheme across the application +Optimised for mobile users, with special attention to mobile-first design + +Thanks to: +https://github.com/krates98/tarotcardapi for images and more. ## The problem diff --git a/backend/package.json b/backend/package.json index 3b9df224eb..226a9fcc6c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "@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", "dotenv": "^17.2.1", diff --git a/backend/server.js b/backend/server.js index 7d331d7946..0a1d940a43 100644 --- a/backend/server.js +++ b/backend/server.js @@ -9,6 +9,7 @@ import mongoose from 'mongoose'; 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; @@ -44,6 +45,7 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' })); app.use('/api/auth', authRouter); app.use('/api/secret', secretRouter); app.use('/api/readings', readingsRouter); +app.use('/api/tarot', tarotRouter); // Demo root app.get('/', (_req, res) => { 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/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/src/Secret.jsx b/frontend/src/Secret.jsx index 7718ae7918..bc855139e6 100644 --- a/frontend/src/Secret.jsx +++ b/frontend/src/Secret.jsx @@ -1,44 +1,57 @@ // frontend/src/pages/Secret.jsx -import { useEffect, useState } from 'react'; -import { useAuth } from '../context/AuthContext'; +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 { token, user } = useAuth(); - const api = import.meta.env.VITE_API_URL; - const [msg, setMsg] = useState(''); - const [err, setErr] = useState(''); + const [refreshKey, setRefreshKey] = useState(0); + const [cards, setCards] = useState([]); - useEffect(() => { - if (!token) { - setErr('You must be logged in.'); - return; + 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); } - - let cancelled = false; - - (async () => { - try { - const res = await fetch(`${api}/api/secret`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); - if (!cancelled) setMsg(data.message); - } catch (e) { - if (!cancelled) setErr(e.message || 'Failed to load secret'); - } - })(); - - return () => { cancelled = true; }; - }, [api, token]); + } return ( -
-

Secret API

-

User: {user?.name}

+ + + +

Quick draw from API

+ +
+ {cards.map((c) => ( +
+ {c.name} { e.currentTarget.src = '/card-back.png'; }} + /> +
{c.name}
+ {c.meaning_up &&
{c.meaning_up}
} +
+ ))} +
+
- {msg &&

{msg}

} - {err &&

{err}

} -
+ setRefreshKey(k => k + 1)} /> + + + ); } + diff --git a/frontend/src/lib/tarotImages.js b/frontend/src/lib/tarotImages.js new file mode 100644 index 0000000000..315d7a950d --- /dev/null +++ b/frontend/src/lib/tarotImages.js @@ -0,0 +1,67 @@ +// frontend/src/lib/tarotImages.js + +// Use Special:FilePath so we don't need hashed CDN paths +const WIKI = 'https://commons.wikimedia.org/wiki/Special:FilePath'; + +// Exact, stable filenames for the Major Arcana +const MAJOR_FILES = { + 'The Fool': 'RWS_Tarot_00_Fool.jpg', + 'The Magician': 'RWS_Tarot_01_Magician.jpg', + 'The High Priestess': 'RWS_Tarot_02_High_Priestess.jpg', + 'The Empress': 'RWS_Tarot_03_Empress.jpg', + 'The Emperor': 'RWS_Tarot_04_Emperor.jpg', + 'The Hierophant': 'RWS_Tarot_05_Hierophant.jpg', + 'The Lovers': 'RWS_Tarot_06_Lovers.jpg', + 'The Chariot': 'RWS_Tarot_07_Chariot.jpg', + 'Strength': 'RWS_Tarot_08_Strength.jpg', + 'The Hermit': 'RWS_Tarot_09_Hermit.jpg', + 'Wheel of Fortune': 'RWS_Tarot_10_Wheel_of_Fortune.jpg', + 'Justice': 'RWS_Tarot_11_Justice.jpg', + 'The Hanged Man': 'RWS_Tarot_12_Hanged_Man.jpg', + 'Death': 'RWS_Tarot_13_Death.jpg', + 'Temperance': 'RWS_Tarot_14_Temperance.jpg', + 'The Devil': 'RWS_Tarot_15_Devil.jpg', + 'The Tower': 'RWS_Tarot_16_Tower.jpg', + 'The Star': 'RWS_Tarot_17_Star.jpg', + 'The Moon': 'RWS_Tarot_18_Moon.jpg', + 'The Sun': 'RWS_Tarot_19_Sun.jpg', + 'Judgement': 'RWS_Tarot_20_Judgement.jpg', + 'The World': 'RWS_Tarot_21_World.jpg', +}; + +// Minor ranks → number used in filenames (Ace=01 … King=14) +const RANK_TO_NUM = { + Ace: '01', Two: '02', Three: '03', Four: '04', Five: '05', + Six: '06', Seven: '07', Eight: '08', Nine: '09', Ten: '10', + Page: '11', Knight: '12', Queen: '13', King: '14', +}; + +// Build the filename pattern for minors: e.g. "Ace of Wands" → "Wands01.jpg" +function fileForMinor(name) { + // Name usually looks like "Ace of Wands", "Three of Cups", etc. + const m = /^([A-Za-z]+)\s+of\s+([A-Za-z]+)$/.exec(name); + if (!m) return null; + const rank = m[1]; + let suit = m[2]; + + // Normalize common synonyms if needed + if (suit.toLowerCase() === 'coins') suit = 'Pentacles'; + + const num = RANK_TO_NUM[rank]; + if (!num) return null; + return `${suit}${num}.jpg`; // e.g. "Swords04.jpg" +} + +// card is one item from tarotapi.dev: { name, type, ... } +export function imageUrlForCard(card) { + // Try majors map + const mf = MAJOR_FILES[card.name]; + if (mf) return `${WIKI}/${encodeURIComponent(mf)}`; + + // Try minors + const minor = fileForMinor(card.name); + if (minor) return `${WIKI}/${encodeURIComponent(minor)}`; + + // Fallback image in /public + return '/card-back.svg'; +} diff --git a/frontend/src/pages/Secret.jsx b/frontend/src/pages/Secret.jsx index 18a9f9265e..2410305f05 100644 --- a/frontend/src/pages/Secret.jsx +++ b/frontend/src/pages/Secret.jsx @@ -1,14 +1,56 @@ // frontend/src/pages/Secret.jsx import { useState } from 'react'; -import { Page, Grid } from '../ui/components'; +import { Page, Grid, Card } from '../ui/components'; 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; // normalize shape + setCards(list || []); + } catch (e) { + alert(e?.response?.data?.error || e.message); + } + } + return ( + {/* NEW: Quick draw */} + +

Quick draw from API

+ + +
+ {cards.map((c) => ( +
+ {c.name} { e.currentTarget.src = '/card-back.svg'; }} + /> +
{c.name}
+ {c.meaning_up &&
{c.meaning_up}
} +
+ ))} +
+
+ + {/* Your existing parts */} setRefreshKey(k => k + 1)} />
diff --git a/frontend/src/services/tarot.js b/frontend/src/services/tarot.js new file mode 100644 index 0000000000..52f76c2d25 --- /dev/null +++ b/frontend/src/services/tarot.js @@ -0,0 +1,12 @@ +// frontend/src/services/tarot.js +import api from '../lib/api'; // your axios instance that prefixes VITE_API_URL + +// GET /api/tarot/draw?n=3 → returns { cards: [...] } (or sometimes just an array) +// We'll normalize in the page. +export const drawRandom = (n = 3) => + api.get('/api/tarot/draw', { params: { n } }).then(r => r.data); + +// (optional) list all cards if you want it later +export const listAllCards = () => + api.get('/api/tarot/cards').then(r => r.data); + From 86e5d1b2e39aae0e92018d7d87ba8757d26d3c7a Mon Sep 17 00:00:00 2001 From: Ulrika Einebrant Date: Wed, 27 Aug 2025 23:02:17 +0200 Subject: [PATCH 10/29] feat: tarot draws & UI polish Home: one-card guidance (upright/reversed) + meanings. Secret: choose one/three-card draw with hover descriptions and per-card draw; save reading. UI: TarotCard component, fallback card-back.svg; responsive tweaks. Services: drawOneCard/drawThreeCards; 78-card images via Wikimedia. --- frontend/src/components/ReadingsList.jsx | 75 ++++++----- frontend/src/components/TarotCard.jsx | 25 ++++ frontend/src/index.css | 51 ++++++++ frontend/src/pages/Home.jsx | 37 ++++++ frontend/src/pages/Secret.jsx | 155 ++++++++++++++++------- frontend/src/services/tarot.js | 24 +++- 6 files changed, 290 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/TarotCard.jsx diff --git a/frontend/src/components/ReadingsList.jsx b/frontend/src/components/ReadingsList.jsx index 16bf4ba428..1c37031776 100644 --- a/frontend/src/components/ReadingsList.jsx +++ b/frontend/src/components/ReadingsList.jsx @@ -1,12 +1,12 @@ -// frontend/src/components/ReadingsList.jsx import { useEffect, useState } from 'react'; -import { listReadings, deleteReading } from '../services/readings'; -import { Card, H2, Grid, Button, Hint } from '../ui/components'; +import { listReadings, deleteReading, updateReading } from '../services/readings'; export default function ReadingsList({ refreshKey = 0 }) { const [data, setData] = useState({ items: [], total: 0 }); const [loading, setLoading] = useState(true); const [err, setErr] = useState(''); + const [editing, setEditing] = useState(null); + const [draft, setDraft] = useState(''); const load = async () => { try { @@ -25,37 +25,54 @@ export default function ReadingsList({ refreshKey = 0 }) { const onDelete = async (id) => { if (!confirm('Delete this reading?')) return; - try { - await deleteReading(id); - await load(); - } catch (e) { - alert(e?.response?.data?.error || e.message); - } + await deleteReading(id); + await load(); + }; + + 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); + await load(); }; return ( - -

My Readings

- Total: {data.total} + <> {loading &&

Loading…

} {err &&

{err}

} - - {data.items.map(r => ( - -

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

-

Spread: {r.spread}

- {r.notes &&

{r.notes}

} - {r.tags?.length > 0 &&

#{r.tags.join(' #')}

} -
    - {r.cards.map((c, i) => ( -
  • {c.position}: {c.id}{c.reversed ? ' (reversed)' : ''}
  • - ))} -
- -
- ))} -
-
+ {data.items.map(r => ( +
+

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

+

Spread: {r.spread}

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