diff --git a/.gitignore b/.gitignore index ea850c7..7502944 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,13 @@ dist-ssr *.njsproj *.sln *.sw? + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ diff --git a/package-lock.json b/package-lock.json index a775d28..38cad56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1633,6 +1634,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1674,6 +1676,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1782,6 +1785,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2037,6 +2041,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3042,6 +3047,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3102,6 +3108,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3111,6 +3118,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3400,6 +3408,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3521,6 +3530,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/api/get-bucketlist.js b/src/api/get-bucketlist.js new file mode 100644 index 0000000..35ac1a0 --- /dev/null +++ b/src/api/get-bucketlist.js @@ -0,0 +1,19 @@ +async function getBucketlist(bucketlistId) { + const url = `${import.meta.env.VITE_API_URL}/bucketlists/${bucketlistId}`; + const response = await fetch(url, { method: "GET" }); + + if (!response.ok) { + const fallbackError = `Error fetching bucketlist with id ${bucketlistId}`; + + const data = await response.json().catch(() => { + throw new Error(fallbackError); + }); + + const errorMessage = data?.detail ?? fallbackError; + throw new Error(errorMessage); + } + + return await response.json(); +} + +export default getBucketlist; \ No newline at end of file diff --git a/src/components/RegisterForm.jsx b/src/components/RegisterForm.jsx new file mode 100644 index 0000000..a1576d9 --- /dev/null +++ b/src/components/RegisterForm.jsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +//import postRegister from "../api/post-register.js"; +//import postLogin from "../api/post-login.js"; +import { useNavigate, useOutletContext } from "react-router-dom"; + +function RegisterForm() { + const navigateTo = useNavigate(); + const { setIsLoggedIn } = useOutletContext(); + + const [formData, setFormData] = useState({ + username: "", + email: "", + password: "", + password2: "", + firstName: "", + lastName: "", + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (event) => { + const { id, value } = event.target; + setFormData((prevData) => ({ + ...prevData, + [id]: value, + })); + // Clear error for this field when user starts typing + if (errors[id]) { + setErrors((prev) => ({ ...prev, [id]: "" })); + } +}; + + const handleSubmit = async (event) => { + event.preventDefault(); + setErrors({}); + + // Validation + const newErrors = {}; + if (!formData.username) newErrors.username = "Username is required"; + if (!formData.email) newErrors.email = "Email is required"; + if (!formData.password) newErrors.password = "Password is required"; + if (formData.password !== formData.password2) { + newErrors.password2 = "Passwords don't match"; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + setIsLoading(true); + + try { + // Register the user COME BACK TO THIS AFTER post-register.js and post-login.js are ACTIONED + //await postRegister( + //formData.username, + //formData.email, + //formData.password, + //formData.firstName, + //formData.lastName + //); + + // Automatically log them in + //const loginResponse = await postLogin(formData.username, formData.password); + //window.localStorage.setItem("token", loginResponse.token); + //window.localStorage.setItem("userId", loginResponse.user_id); + //setIsLoggedIn(true); + + // Redirect to home + //navigateTo("/account"); + console.log("Register from submitted", formData); + navigateTo("/"); + } catch (error) { + setErrors({ general: error.message || "Registration failed. Please try again." }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {errors.general && ( +
{errors.general}
+ )} + +
+ + + {errors.username && {errors.username}} +
+ +
+ + + {errors.email && {errors.email}} +
+ +
+ + +
+ +
+ + +
+ +
+ + + {errors.password && {errors.password}} +

Password must be at least 8 characters long, and not too common

+
+ +
+ + + {errors.password2 && {errors.password2}} +
+ + +
+ ); +} + +export default RegisterForm; \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index ef4eb10..94c34c3 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -6,10 +6,14 @@ import Layout from "./layout"; import HomePage from "./pages/HomePage.jsx"; import LoginPage from "./pages/LoginPage.jsx"; import AccountPage from "./pages/AccountPage.jsx"; +import BucketListsPage from "./pages/BucketListsPage"; +import SingleListView from "./pages/SingleListView.jsx"; +import RegisterPage from "./pages/RegisterPage"; import InviteAcceptPage from "./pages/InviteAcceptPage.jsx"; import { AuthProvider } from "./components/AuthProvider.jsx" import GoogleOAuthCallback from "./components/GoogleOAuthCallback.jsx"; +import NotFound from "./pages/NotFoundPage"; import "./main.css" const router = createBrowserRouter([ @@ -23,10 +27,12 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "/login", element: }, + { path: "/register", element: }, { path: "/dashboard", element: }, { path: "/oauth/google/callback", element: }, - { path: "/invites/:token", element: }, - { path: "/bucketlists/:id", element: }, + { path: "/bucketlists", element: }, + { path: "/bucketlists/:id", element: }, + { path: "*", element: }, ], }, ]); diff --git a/src/pages/AccountPage.jsx b/src/pages/AccountPage.jsx deleted file mode 100644 index ee75e93..0000000 --- a/src/pages/AccountPage.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, useEffect } from "react"; -import { useAuth } from "../hooks/use-auth"; -import { Navigate } from "react-router-dom"; -import useUser from "../hooks/use-user"; -import Dashboard from "../components/Dashboard/Dashboard"; - -function AccountPage() { - const { auth, setAuth } = useAuth(); - const { user, isLoading, error } = useUser(); - const [currentUser, setCurrentUser] = useState(null); - - useEffect(() => { - if (user) setCurrentUser(user); - }, [user]); - - if (!auth?.access) return ; - if (isLoading || !currentUser) - return ( -

- Loading your account... -

- ); - if (error) - return ( -

{error.message}

- ); - - const handleUserUpdate = (updatedUser) => { - setCurrentUser(updatedUser); - setAuth((prev) => ({ ...prev, user: updatedUser })); - }; - - return ; -} - -export default AccountPage; diff --git a/src/pages/BucketListsPage.jsx b/src/pages/BucketListsPage.jsx new file mode 100644 index 0000000..9259d3e --- /dev/null +++ b/src/pages/BucketListsPage.jsx @@ -0,0 +1,3 @@ +export default function BucketListsPage() { + return

Bucket Lists

+} \ No newline at end of file diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index 98c919e..35a069c 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -118,4 +118,4 @@ function LoginPage() { ); } -export default LoginPage; \ No newline at end of file +export default LoginPage; diff --git a/src/pages/NotFoundPage.jsx b/src/pages/NotFoundPage.jsx new file mode 100644 index 0000000..3fadffe --- /dev/null +++ b/src/pages/NotFoundPage.jsx @@ -0,0 +1,10 @@ +import { Link } from "react-router-dom"; + +export default function NotFound() { + return ( +
+

404 - Page Not Found

+ Back to Home +
+ ) +} \ No newline at end of file diff --git a/src/pages/RegisterPage.jsx b/src/pages/RegisterPage.jsx new file mode 100644 index 0000000..d6dd4a5 --- /dev/null +++ b/src/pages/RegisterPage.jsx @@ -0,0 +1,19 @@ +import { Link } from "react-router-dom"; +import RegisterForm from "../components/RegisterForm"; + +function RegisterPage() { + return ( +
+
+

Create Account

+

Join Bucket List

+ +
+ Already have an account? Login here +
+
+
+ ); +} + +export default RegisterPage; \ No newline at end of file diff --git a/src/pages/SingleListView.jsx b/src/pages/SingleListView.jsx new file mode 100644 index 0000000..6c82836 --- /dev/null +++ b/src/pages/SingleListView.jsx @@ -0,0 +1,483 @@ +import { useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; + +// Kickit Colours +const COLORS = { + primary: "#6B4EAA", + accent: "#FF5A5F", + border: "#A78BFA", + bodyText: "#0F172A", + mutedText: "#6B6880", + surface: "#EEEAF7", + ctaText: "#FFFFFF", + background: "#F7F6FB", + white: "#FFFFFF", +}; + +// Tag Colours +const TAG_COLORS = { + travel: { bg: "#EDE9F7", text: "#6B4EAA", dot: "#6B4EAA" }, + food: { bg: "#FFF0F0", text: "#FF5A5F", dot: "#FF5A5F" }, + adventure: { bg: "#EDE9F7", text: "#7C3AED", dot: "#7C3AED" }, + wellness: { bg: "#F0FDF4", text: "#16A34A", dot: "#16A34A" }, + social: { bg: "#FFF0F0", text: "#FF5A5F", dot: "#FF5A5F" }, + learning: { bg: "#EDE9F7", text: "#6B4EAA", dot: "#6B4EAA" }, + creative: { bg: "#EDE9F7", text: "#A78BFA", dot: "#A78BFA" }, + other: { bg: "#EEEAF7", text: "#6B6880", dot: "#6B6880" }, +}; + +// Helpers +function formatDate(iso) { + if (!iso) return null; + return new Date(iso).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" }); +} +function formatTime(iso) { + if (!iso) return null; + return new Date(iso).toLocaleTimeString("en-AU", { hour: "2-digit", minute: "2-digit" }); +} +function daysUntil(iso) { + return Math.ceil((new Date(iso) - new Date()) / (1000 * 60 * 60 * 24)); +} + +// Icons +const TagIcon = () => ( + + + + +); +const CheckIcon = () => ( + + + +); +const ClockIcon = () => ( + + + +); +const CalendarIcon = () => ( + + + +); +const GlobeIcon = () => ( + + + +); +const LockIcon = () => ( + + + +); +const TrashIcon = () => ( + + + + +); +const UserIcon = () => ( + + + +); + +// Delete Modal +function DeleteConfirmModal({ item, onConfirm, onCancel }) { + return ( +
+
+
🗑️
+

+ Get rid of it? +

+

+ "{item.title}" is gone. +

+
+ + +
+
+
+ ); +} + +// Item Card +function ItemCard({ item, onDelete }) { + const [hovering, setHovering] = useState(false); + const tag = TAG_COLORS[item.tag] || TAG_COLORS.other; + + return ( +
setHovering(true)} + onMouseLeave={() => setHovering(false)} + style={{ + background: item.is_completed ? COLORS.background : COLORS.white, + border: `1px solid ${hovering ? COLORS.border : "#E2DAF5"}`, + borderRadius: "16px", + padding: "20px 24px", + transition: "all 0.2s ease", + transform: hovering ? "translateY(-2px)" : "none", + boxShadow: hovering + ? "0 8px 32px rgba(107,78,170,0.12)" + : "0 1px 4px rgba(107,78,170,0.05)", + opacity: item.is_completed ? 0.75 : 1, + }} + > + {/* Top row */} +
+
+ {/* Completion circle */} +
+ {item.is_completed && } +
+ +
+

+ {item.title} +

+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ + {/* Delete button */} + +
+ + {/* Meta row */} +
+ {/* Tag badge */} + + + + {item.tag} + + + + {formatDate(item.date_created)} + + + {formatTime(item.date_created)} + + + {item.created_by?.username} + + + {item.is_completed && item.completed_at && ( + + Completed {formatDate(item.completed_at)} + + )} +
+
+ ); +} + +// Main Page +export default function SingleListView() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [filter, setFilter] = useState("all"); + + const { id } = useParams(); + const API_BASE = import.meta.env.VITE_API_URL; + +useEffect(() => { + async function fetchData() { + try { + const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcyOTczMTE3LCJpYXQiOjE3NzI5Njk1MzIsImp0aSI6ImI0MjYxOTdiOTcxYjQyMTJiNDM3MGYyZDU3OGY1MjQ4IiwidXNlcl9pZCI6IjEifQ.-mJDUM68HuuN1Huab5kWN-oW21r8eRHhGreE-26Xe2o"; + + const [listRes, itemsRes] = await Promise.all([ + fetch(`${API_BASE}/bucketlists/${id}/`, { + credentials: "include", + headers: { + "Authorization": `Bearer ${token}` + } + }), + fetch(`${API_BASE}/bucketlists/${id}/items/`, { + credentials: "include", + headers: { + "Authorization": `Bearer ${token}` + } + }), + ]); + if (!listRes.ok || !itemsRes.ok) throw new Error("Failed to fetch data"); + const list = await listRes.json(); + const items = await itemsRes.json(); + setData({ ...list, items }); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + fetchData(); + }, [id]); + + if (loading) return ( +
+ Loading... +
+ ); + + if (error || !data) return ( +
+ {error || "Something went wrong."} +
+ ); + + const completed = data.items.filter(i => i.is_completed).length; + const total = data.items.length; + const progress = total > 0 ? Math.round((completed / total) * 100) : 0; + const daysLeft = data.has_deadline && data.deadline ? daysUntil(data.deadline) : null; + + const filteredItems = data.items.filter(item => { + if (filter === "complete") return item.is_completed; + if (filter === "pending") return !item.is_completed; + return true; + }); + + const handleDeleteConfirm = async () => { + try { + const res = await fetch(`${API_BASE}/items/${deleteTarget.id}/`, { + method: "DELETE", credentials: "include", + }); + if (!res.ok) throw new Error("Delete failed"); + setData(prev => ({ ...prev, items: prev.items.filter(i => i.id !== deleteTarget.id) })); + } catch (err) { + alert(err.message); + } finally { + setDeleteTarget(null); + } + }; + + return ( +
+ + +
+ + {/* Breadcrumb */} +
+ My Lists + + Detail View +
+ + {/* Header */} +
+
+
+

+ {data.title} +

+

+ {data.description} +

+
+ + {/* Badges */} +
+ + {data.is_public ? : } + {data.is_public ? "Public" : "Private"} + + + + + {data.is_open ? "Open" : "Archived"} + +
+
+ + {/* Meta row */} +
+ + Created {formatDate(data.date_created)} + + + {data.owner?.username} + + {daysLeft !== null && ( + + + {daysLeft > 0 ? `${daysLeft} days until deadline` : "Deadline passed"} + + )} +
+
+ + {/* Progress card */} +
+
+ + Progress + + + {completed}/{total} + +
+
+
+
+
{progress}% complete
+
+ + {/* Filter tabs */} +
+ {["all", "pending", "complete"].map(f => ( + + ))} +
+ + {/* Items list */} +
+ {filteredItems.length === 0 ? ( +
+ Nothing here yet — make a plan, do something new! +
+ ) : filteredItems.map(item => ( + + ))} +
+
+ + {/* Delete modal */} + {deleteTarget && ( + setDeleteTarget(null)} + /> + )} +
+ ); +} \ No newline at end of file