diff --git a/client/index.html b/client/index.html index f3dcf16..0738587 100644 --- a/client/index.html +++ b/client/index.html @@ -2,7 +2,7 @@ - + t14-project-2 diff --git a/client/public/bookishFavicon.svg b/client/public/bookishFavicon.svg new file mode 100644 index 0000000..8c84107 --- /dev/null +++ b/client/public/bookishFavicon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 25cc53c..d91ecbc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,24 +1,22 @@ -import { BrowserRouter, Routes, Route } from "react-router"; -import AppShell from "./components/AppShell/AppShell"; -import { BrowsePage } from "./pages/BrowsePage"; +import { BrowserRouter, Routes, Route } from 'react-router'; +import { AppShell } from './components/AppShell/AppShell'; +import { BookDetailPage } from './pages/BookDetailPage/BookDetailPage'; +import { BrowsePage } from './pages/BrowsePage/BrowsePage'; /* ---------- Temporary placeholder pages (delete when the real ones exist) ---------- */ function ShelfPlaceholder() { return

My shelf page

; } -function DetailPlaceholder() { - return

Book detail page

; -} /* ---------- Routes ---------- */ -export default function App() { +export function App() { return ( }> } /> } /> - } /> + } /> diff --git a/client/src/components/AppShell/AppShell.module.css b/client/src/components/AppShell/AppShell.module.css index 4228fa6..71b6bbf 100644 --- a/client/src/components/AppShell/AppShell.module.css +++ b/client/src/components/AppShell/AppShell.module.css @@ -6,7 +6,9 @@ flex-direction: column; min-height: 100vh; min-height: 100dvh; /* mobile browsers: ignores the collapsing address bar */ - padding-bottom: calc(var(--nav-height) + var(--space-3)); /* room so the fixed nav doesn't cover the footer */ + padding-bottom: calc( + var(--nav-height) + var(--space-3) + ); /* room so the fixed nav doesn't cover the footer */ background: var(--color-page); color: var(--color-text); } @@ -107,8 +109,8 @@ .footer { display: flex; flex-direction: column; - align-items: center; - text-align: center; + align-items: center; + text-align: center; gap: var(--space-2); } @@ -130,7 +132,7 @@ /* ================= Desktop: from 768px and up ================= */ @media (min-width: 48rem) { .shell { - padding-bottom: var(--space-1); + padding-bottom: var(--space-1); } .inner { @@ -149,7 +151,7 @@ min-height: 4.5rem; } - .logo { + .logo { align-self: center; } @@ -196,4 +198,4 @@ align-items: center; justify-content: space-between; } -} \ No newline at end of file +} diff --git a/client/src/components/AppShell/AppShell.tsx b/client/src/components/AppShell/AppShell.tsx index ec76433..43ae90f 100644 --- a/client/src/components/AppShell/AppShell.tsx +++ b/client/src/components/AppShell/AppShell.tsx @@ -1,86 +1,85 @@ // ===== Imports ===== -import { Outlet, Link, NavLink, useNavigate, useSearchParams } from "react-router-dom"; +import { Outlet, Link, NavLink, useNavigate, useSearchParams } from 'react-router-dom'; import styles from './AppShell.module.css'; -import { SearchField } from "../SearchField/SearchField"; -import { LogoIcon } from "../../icons/LogoIcon"; -import { BrowseIcon } from "../../icons/BrowseIcon"; -import { ShelfIcon } from "../../icons/ShelfIcon"; +import { SearchField } from '../SearchField/SearchField'; +import { LogoIcon } from '../../icons/LogoIcon'; +import { BrowseIcon } from '../../icons/BrowseIcon'; +import { ShelfIcon } from '../../icons/ShelfIcon'; -export default function AppShell() { +export function AppShell() { + // ===== Read the current search ===== + const [searchParam] = useSearchParams(); + const q = searchParam.get('q') ?? ''; - // ===== Read the current search ===== - const [searchParam] = useSearchParams(); - const q = searchParam.get("q") ?? ""; + const navigate = useNavigate(); - const navigate = useNavigate(); - - function handleSearch(query: string) { - if (query === "") { - navigate("/"); - } else { - navigate("/?q=" + encodeURIComponent(query)); - } + function handleSearch(query: string) { + if (query === '') { + navigate('/'); + } else { + navigate('/?q=' + encodeURIComponent(query)); } + } - return ( - // ===== Shell wrapper ===== -
- {/* ===== Header ===== */} -
-
- {/* ===== Logo ===== */} - - - Bookish - + return ( + // ===== Shell wrapper ===== +
+ {/* ===== Header ===== */} +
+
+ {/* ===== Logo ===== */} + + + Bookish + - {/* ===== Search field ===== */} -
- -
+ {/* ===== Search field ===== */} +
+ +
- {/* ===== Main navigation ===== */} - -
-
+ {/* ===== Main navigation ===== */} + +
+
- {/* ===== Header divider ===== */} - - {showShelf && {/* Ribbon: sibling of the link, NOT inside it */} - + ); @@ -73,26 +78,3 @@ function StarRow({ value }: { value: number }) { ); } - -/* ---------- FavoriteRibbon: bookmark toggle ---------- */ -function FavoriteRibbon({ - title, - isSaved, - onToggle, -}: { - title: string; - isSaved: boolean; - onToggle: () => void; -}) { - return ( - - ); -} diff --git a/client/src/components/BookCover/BookCover.tsx b/client/src/components/BookCover/BookCover.tsx index dda4490..03b5b1f 100644 --- a/client/src/components/BookCover/BookCover.tsx +++ b/client/src/components/BookCover/BookCover.tsx @@ -10,22 +10,22 @@ function toneFor(key: string) { return hash % TONE_COUNT; } -type Props = { +type BookCoverProps = { id: string; title: string; author?: string; - coverUrl?: string; + coverURL?: string; eager?: boolean; // true for the hero on the detail page }; -export function BookCover({ id, title, author, coverUrl, eager = false }: Props) { +export function BookCover({ id, title, author, coverURL, eager = false }: BookCoverProps) { const [failed, setFailed] = useState(false); - if (coverUrl && !failed) { + if (coverURL && !failed) { return ( setFailed(true)} diff --git a/client/src/components/BookGrid/BookGrid.module.css b/client/src/components/BookGrid/BookGrid.module.css index a206665..d0a30b9 100644 --- a/client/src/components/BookGrid/BookGrid.module.css +++ b/client/src/components/BookGrid/BookGrid.module.css @@ -10,10 +10,10 @@ gap: 2.75rem 1.5rem; /* row gap, column gap */ } -/* ===== Only when the shelf is shown ===== */ +/* ===== Only when the shelf plank is shown ===== */ /* Each card's .plank::before (in BookCard.module.css) is stretched far to both sides; this clips it at the edge of the grid */ -.withShelf { +.withShelfPlank { overflow-x: clip; padding-inline: var(--space-2); /* room so focus rings and shadows aren't clipped */ } diff --git a/client/src/components/BookGrid/BookGrid.tsx b/client/src/components/BookGrid/BookGrid.tsx index c345139..91d1b6f 100644 --- a/client/src/components/BookGrid/BookGrid.tsx +++ b/client/src/components/BookGrid/BookGrid.tsx @@ -8,21 +8,21 @@ type BookGridProps = { savedIds: Set; // which books are on My shelf ratings: Record; // bookId → the user's own rating (missing = unrated) onToggleSaved: (bookId: string) => void; - showShelf?: boolean; // decorative shelf under each row; off on Browse, on for My shelf + showShelfPlank?: boolean; // decorative shelf plank under each row; off on Browse, on for My shelf }; /* ---------- BookGrid: one list, one card per book ---------- */ -// Doesn't know which page it's on. The page decides which books to pass and whether to show the shelf. +// Doesn't know which page it's on. The page decides which books to pass and whether to show the shelf plank. export function BookGrid({ books, savedIds, ratings, onToggleSaved, - showShelf = false, + showShelfPlank = false, }: BookGridProps) { return ( // The modifier class holds the CSS that only the shelf needs (clipping) -
    +
      {books.map((book) => ( onToggleSaved(book.id)} // the grid binds the id, so a card doesn't need to know it - showShelf={showShelf} + showShelfPlank={showShelfPlank} /> ))}
    diff --git a/client/src/components/Button/Button.module.css b/client/src/components/Button/Button.module.css index 0fad132..9e1eb1a 100644 --- a/client/src/components/Button/Button.module.css +++ b/client/src/components/Button/Button.module.css @@ -1,38 +1,43 @@ .button { - height: 44px; - border-radius: 12px; - border: 1.5px solid transparent; - font-size: 15px; - font-weight: 700; - cursor: pointer; + height: var(--tap-target); + border-radius: var(--radius-md); + border: 1.5px solid transparent; + font-size: 15px; + font-weight: 700; + cursor: pointer; } .primary { - border-color: var(--color-focus); - background: transparent; - color: var(--color-focus); + border-color: var(--color-focus); + background: transparent; + color: var(--color-focus); } .secondary { - border-color: var(--color-accent); - background: var(--color-accent); - color: var(--color-on-accent); + border-color: var(--color-accent); + background: var(--color-accent); + color: var(--color-on-accent); } .narrow { - padding: 0 20px; + padding: 0 20px; } .wide { - padding: 0 40px; + padding: 0 40px; } -.button:disabled { - opacity: 0.5; - cursor: not-allowed; +/* Underlined text, no box. Placed after the sizes so its padding wins */ +.text { + padding: 0 var(--space-2); + background: none; + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-soft); + text-decoration: underline; } -.button:focus-visible { - outline: 2px solid var(--color-focus); - outline-offset: 3px; -} \ No newline at end of file +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/client/src/components/Button/Button.tsx b/client/src/components/Button/Button.tsx index 711a58f..bffcf9c 100644 --- a/client/src/components/Button/Button.tsx +++ b/client/src/components/Button/Button.tsx @@ -1,30 +1,32 @@ import styles from './Button.module.css'; type ButtonProps = { - children: React.ReactNode; - variant?: "primary" | "secondary"; - size?: "narrow" | "wide"; - type?: "button" | "submit"; - onClick?: () => void; - disabled?: boolean; + children: React.ReactNode; + variant?: 'primary' | 'secondary' | 'text'; + size?: 'narrow' | 'wide'; + type?: 'button' | 'submit'; + onClick?: () => void; + disabled?: boolean; + className?: string; }; export function Button({ - children, - variant = "primary", - size = "narrow", - type = "button", - onClick, - disabled = false, + children, + variant = 'primary', + size = 'narrow', + type = 'button', + onClick, + disabled = false, + className, }: ButtonProps) { - return ( - - ); + return ( + + ); } diff --git a/client/src/components/ConfirmDialog/ConfirmDialog.module.css b/client/src/components/ConfirmDialog/ConfirmDialog.module.css new file mode 100644 index 0000000..b1b2da1 --- /dev/null +++ b/client/src/components/ConfirmDialog/ConfirmDialog.module.css @@ -0,0 +1,30 @@ +.dialog { + margin: auto; + width: min(26rem, calc(100vw - 2rem)); + padding: var(--space-6); + background: var(--color-card); + color: var(--color-text); + border: 1px solid var(--color-line); + border-radius: var(--radius-md); +} + +.dialog::backdrop { + background: rgb(0 0 0 / 45%); +} + +.title { + margin-block-end: var(--space-3); + font-size: var(--text-lg); +} + +.message { + margin-block-end: var(--space-6); + line-height: var(--leading); + color: var(--color-text-soft); +} + +.actions { + display: flex; + align-items: center; + gap: var(--space-2); +} diff --git a/client/src/components/ConfirmDialog/ConfirmDialog.tsx b/client/src/components/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 0000000..eec9d0c --- /dev/null +++ b/client/src/components/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,59 @@ +import { useEffect, useId, useRef } from 'react'; +import { Button } from '../Button/Button'; +import styles from './ConfirmDialog.module.css'; + +type ConfirmDialogProps = { + open: boolean; + title: string; + message: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; +}; + +// ===== ConfirmDialog: "are you sure?" popup for destructive actions ===== +export function ConfirmDialog({ + open, + title, + message, + confirmLabel, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const dialogRef = useRef(null); + const titleId = useId(); + + // The native dialog has its own open/closed state, so sync it with the `open` prop + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + }, [open]); + + return ( + { + // Esc: let our own state do the closing, not the browser + event.preventDefault(); + onCancel(); + }} + > +

    + {title} +

    +

    {message}

    +
    + + +
    +
    + ); +} diff --git a/client/src/components/Eyebrow/Eyebrow.module.css b/client/src/components/Eyebrow/Eyebrow.module.css new file mode 100644 index 0000000..fa7e77e --- /dev/null +++ b/client/src/components/Eyebrow/Eyebrow.module.css @@ -0,0 +1,15 @@ +.eyebrow { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent); +} + +.sparkle { + width: 0.875rem; + height: 0.875rem; +} diff --git a/client/src/components/Eyebrow/Eyebrow.tsx b/client/src/components/Eyebrow/Eyebrow.tsx new file mode 100644 index 0000000..74733a0 --- /dev/null +++ b/client/src/components/Eyebrow/Eyebrow.tsx @@ -0,0 +1,17 @@ +// Weird naming convention, but this is the small piece of text sitting above the header, with a sparkle icon +import { SparkleIcon } from '../../icons/SparkleIcon'; +import styles from './Eyebrow.module.css'; + +type EyebrowProps = { + children: React.ReactNode; +}; + +// ===== Eyebrow: small uppercase label with a sparkle, above a heading ===== +export function Eyebrow({ children }: EyebrowProps) { + return ( +

    + + {children} +

    + ); +} diff --git a/client/src/components/MyNotesPanel/MyNotesPanel.module.css b/client/src/components/MyNotesPanel/MyNotesPanel.module.css new file mode 100644 index 0000000..bcdf524 --- /dev/null +++ b/client/src/components/MyNotesPanel/MyNotesPanel.module.css @@ -0,0 +1,97 @@ +/* ===== Panel card ===== */ +.panel { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-6); + background: var(--color-card); + border: 1px solid var(--color-line); + border-radius: var(--radius-md); +} + +.heading { + font-size: var(--text-lg); +} + +/* ===== Rating ===== */ +.labelRow { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--space-3); + margin-block-end: 2px; +} + +.label { + font-size: var(--text-sm); + font-weight: 700; + color: var(--color-text-soft); +} + +/* The text Button is 44px tall; negative margins cancel that, so the row keeps its height */ +.clearRating { + margin-block: calc(-1 * var(--space-3)); + margin-inline-end: calc(-1 * var(--space-2)); +} + +/* ===== Note: saved state ===== */ +.noteBlock { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.note { + font-size: var(--text-md); + line-height: var(--leading); + white-space: pre-wrap; /* keeps the user's own line breaks */ + overflow-wrap: anywhere; /* a very long word can't push the card wider */ +} + +.updated { + font-size: var(--text-sm); + color: var(--color-text-soft); +} + +/* ===== Note: empty state ===== */ +.empty { + line-height: var(--leading); + color: var(--color-text-soft); +} + +/* ===== Note: editing state ===== */ +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.textarea { + padding: var(--space-3); + background: var(--color-card); + border: 1px solid var(--color-input-border); + border-radius: var(--radius-md); + font-size: var(--text-md); + line-height: var(--leading); + color: var(--color-text); + resize: none; +} + +.counter { + align-self: flex-end; + font-size: var(--text-sm); + color: var(--color-text-soft); +} + +/* ===== Button row under the note ===== */ +.actions { + display: flex; + align-items: center; + gap: var(--space-2); +} diff --git a/client/src/components/MyNotesPanel/MyNotesPanel.tsx b/client/src/components/MyNotesPanel/MyNotesPanel.tsx new file mode 100644 index 0000000..0a61597 --- /dev/null +++ b/client/src/components/MyNotesPanel/MyNotesPanel.tsx @@ -0,0 +1,143 @@ +import { useId, useState } from 'react'; +import { Button } from '../Button/Button'; +import { StarRatingInput } from '../StarRatingInput/StarRatingInput'; +import styles from './MyNotesPanel.module.css'; + +const MAX_NOTE_LENGTH = 1000; + +type MyNotesPanelProps = { + rating?: number; + note?: string; // undefined or "" = no note + updatedAt?: string; // ISO date of the last note save + onRatingChange: (rating: number) => void; + onClearRating: () => void; + onSaveNote: (note: string) => void; + onDeleteNote: () => void; +}; + +// "2026-10-03T12:00:00.000Z" -> "3 Oct 2026" +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} + +// ===== MyNotesPanel: the user's own rating and note for one book ===== +export function MyNotesPanel({ + rating, + note, + updatedAt, + onRatingChange, + onClearRating, + onSaveNote, + onDeleteNote, +}: MyNotesPanelProps) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(''); // text in the textarea; only saved on "Save note" + const noteFieldId = useId(); + + // Which of the three note states to show + const mode = isEditing ? 'editing' : note ? 'saved' : 'empty'; + + // ===== Handlers ===== + const startEditing = () => { + setDraft(note ?? ''); + setIsEditing(true); + }; + + const cancelEditing = () => { + setIsEditing(false); // the draft is simply thrown away + }; + + const saveDraft = () => { + onSaveNote(draft.trim()); + setIsEditing(false); + }; + + return ( +
    +

    + Your rating and notes +

    + + {/* ===== Rating ===== */} +
    +
    +

    Your rating

    + {rating !== undefined && ( + + )} +
    + +
    + + {/* ===== Note: empty state ===== */} + {mode === 'empty' && ( + <> +

    No notes yet. Rating this book puts it on your shelf.

    +
    + +
    + + )} + + {/* ===== Note: saved state ===== */} + {mode === 'saved' && ( + <> +
    +

    Your note

    +

    {note}

    + {updatedAt &&

    Updated {formatDate(updatedAt)}

    } +
    +
    + + +
    + + )} + + {/* ===== Note: editing state ===== */} + {mode === 'editing' && ( +
    { + event.preventDefault(); // stop the browser's own submit (page reload) + saveDraft(); + }} + > +
    + +