Archived
feat: bookmarks and notes management
- Backend: Django REST Framework API with Bookmark and Note models - ViewSets with user-scoped querysets and select_related for N+1 prevention - Create/List/Detail/Update/Delete endpoints - Batch delete operations - Unique constraint on user+book+page for bookmarks - IsOwner permission class for object-level access control - Full serializer validation (page > 0, non-empty content, duplicate check) - 30+ pytest-django tests covering CRUD, auth, filtering, edge cases - Frontend: React TypeScript components - AnnotationsContext with useReducer for state management - BookmarkList, NoteList, AddAnnotationForm, AnnotationsDashboard - Inline note editing with immediate save - Batch delete support - API client with JWT auto-refresh interceptors - Paginated query hook for infinite scroll support - Responsive CSS with loading/empty states - Infrastructure: Django project with custom User model, JWT auth, CORS - PostgreSQL database models with proper FK and indexes - Django admin configuration for all models
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import api from "@/api/client";
|
||||
import type {
|
||||
Bookmark,
|
||||
CreateBookmarkPayload,
|
||||
Note,
|
||||
CreateNotePayload,
|
||||
UpdateNotePayload,
|
||||
PaginatedResponse,
|
||||
} from "@/types";
|
||||
|
||||
/** Fetch bookmarks for the current user, optionally filtered by book */
|
||||
export async function fetchBookmarks(
|
||||
bookId?: string
|
||||
): Promise<PaginatedResponse<Bookmark>> {
|
||||
const params: Record<string, string> = {};
|
||||
if (bookId) params.book = bookId;
|
||||
const { data } = await api.get<PaginatedResponse<Bookmark>>(
|
||||
"/annotations/bookmarks/",
|
||||
{ params }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Create a new bookmark */
|
||||
export async function createBookmark(
|
||||
payload: CreateBookmarkPayload
|
||||
): Promise<Bookmark> {
|
||||
const { data } = await api.post<Bookmark>(
|
||||
"/annotations/bookmarks/",
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Delete a bookmark by id */
|
||||
export async function deleteBookmark(id: string): Promise<void> {
|
||||
await api.delete(`/annotations/bookmarks/${id}/`);
|
||||
}
|
||||
|
||||
/** Batch delete bookmarks */
|
||||
export async function batchDeleteBookmarks(
|
||||
ids: string[]
|
||||
): Promise<{ deleted: number }> {
|
||||
const { data } = await api.delete<{ deleted: number }>(
|
||||
"/annotations/bookmarks/batch-delete/",
|
||||
{ data: { ids } }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Fetch notes for the current user, optionally filtered by book */
|
||||
export async function fetchNotes(
|
||||
bookId?: string
|
||||
): Promise<PaginatedResponse<Note>> {
|
||||
const params: Record<string, string> = {};
|
||||
if (bookId) params.book = bookId;
|
||||
const { data } = await api.get<PaginatedResponse<Note>>(
|
||||
"/annotations/notes/",
|
||||
{ params }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Create a new note */
|
||||
export async function createNote(payload: CreateNotePayload): Promise<Note> {
|
||||
const { data } = await api.post<Note>("/annotations/notes/", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Update a note's content */
|
||||
export async function updateNote(
|
||||
id: string,
|
||||
payload: UpdateNotePayload
|
||||
): Promise<Note> {
|
||||
const { data } = await api.patch<Note>(
|
||||
`/annotations/notes/${id}/`,
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Delete a note by id */
|
||||
export async function deleteNote(id: string): Promise<void> {
|
||||
await api.delete(`/annotations/notes/${id}/`);
|
||||
}
|
||||
|
||||
/** Batch delete notes */
|
||||
export async function batchDeleteNotes(
|
||||
ids: string[]
|
||||
): Promise<{ deleted: number }> {
|
||||
const { data } = await api.delete<{ deleted: number }>(
|
||||
"/annotations/notes/batch-delete/",
|
||||
{ data: { ids } }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "/api",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Attach JWT token to every request
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Attempt token refresh on 401
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: Array<(token: string) => void> = [];
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
if (error.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
pendingRequests.push((token: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
resolve(api(originalRequest));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (!refreshToken) {
|
||||
throw new Error("No refresh token");
|
||||
}
|
||||
const { data } = await axios.post("/api/auth/token/refresh/", {
|
||||
refresh: refreshToken,
|
||||
});
|
||||
localStorage.setItem("access_token", data.access);
|
||||
pendingRequests.forEach((cb) => cb(data.access));
|
||||
pendingRequests = [];
|
||||
originalRequest.headers.Authorization = `Bearer ${data.access}`;
|
||||
return api(originalRequest);
|
||||
} catch {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
return Promise.reject(error);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from "react";
|
||||
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||
|
||||
interface AddAnnotationFormProps {
|
||||
bookId: string;
|
||||
page: number;
|
||||
locationText?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function AddAnnotationForm({
|
||||
bookId,
|
||||
page,
|
||||
locationText,
|
||||
onClose,
|
||||
}: AddAnnotationFormProps): React.ReactElement {
|
||||
const { addBookmark, addNote } = useAnnotations();
|
||||
const [mode, setMode] = useState<"bookmark" | "note" | null>(null);
|
||||
const [noteContent, setNoteContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "bookmark") {
|
||||
await addBookmark({ book: bookId, page, location_text: locationText });
|
||||
} else if (mode === "note") {
|
||||
if (!noteContent.trim()) {
|
||||
setError("Note content cannot be empty.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
await addNote({
|
||||
book: bookId,
|
||||
page,
|
||||
location_text: locationText,
|
||||
content: noteContent.trim(),
|
||||
});
|
||||
}
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to save annotation."
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="add-annotation-overlay">
|
||||
<div className="add-annotation-modal">
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
<h3>Add to Page {page}</h3>
|
||||
{locationText && (
|
||||
<blockquote className="annotation-quote">
|
||||
“{locationText}”
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
{!mode ? (
|
||||
<div className="mode-selector">
|
||||
<button
|
||||
className="btn btn-block"
|
||||
onClick={() => setMode("bookmark")}
|
||||
>
|
||||
Add Bookmark
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-block btn-secondary"
|
||||
onClick={() => setMode("note")}
|
||||
>
|
||||
Add Note
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="annotation-form">
|
||||
{mode === "note" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="note-content">Note:</label>
|
||||
<textarea
|
||||
id="note-content"
|
||||
className="form-textarea"
|
||||
value={noteContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setNoteContent(e.target.value)
|
||||
}
|
||||
rows={5}
|
||||
placeholder="Write your note here..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setMode(null)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||
import type { AnnotationEntry } from "@/types";
|
||||
|
||||
interface AnnotationsDashboardProps {
|
||||
bookId?: string;
|
||||
onNavigateToPage?: (bookId: string, page: number) => void;
|
||||
}
|
||||
|
||||
export function AnnotationsDashboard({
|
||||
bookId,
|
||||
onNavigateToPage,
|
||||
}: AnnotationsDashboardProps): React.ReactElement {
|
||||
const {
|
||||
mergedAnnotations,
|
||||
loadBookmarks,
|
||||
loadNotes,
|
||||
removeBookmark,
|
||||
removeNote,
|
||||
state,
|
||||
} = useAnnotations();
|
||||
|
||||
React.useEffect(() => {
|
||||
loadBookmarks(bookId);
|
||||
loadNotes(bookId);
|
||||
}, [loadBookmarks, loadNotes, bookId]);
|
||||
|
||||
const handleDelete = async (entry: AnnotationEntry): Promise<void> => {
|
||||
if (entry.kind === "bookmark") {
|
||||
await removeBookmark(entry.id);
|
||||
} else {
|
||||
await removeNote(entry.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.bookmarksLoading || state.notesLoading) {
|
||||
return <div className="annotations-loading">Loading annotations...</div>;
|
||||
}
|
||||
|
||||
if (mergedAnnotations.length === 0) {
|
||||
return (
|
||||
<div className="annotations-empty">
|
||||
No bookmarks or notes yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="annotations-dashboard">
|
||||
<div className="annotations-summary">
|
||||
<span className="summary-count">
|
||||
{state.bookmarks.length} bookmarks
|
||||
</span>
|
||||
<span className="summary-separator">·</span>
|
||||
<span className="summary-count">
|
||||
{state.notes.length} notes
|
||||
</span>
|
||||
</div>
|
||||
<div className="annotations-list">
|
||||
{mergedAnnotations.map((entry: AnnotationEntry) => (
|
||||
<div key={`${entry.kind}-${entry.id}`} className="annotation-card">
|
||||
<div className="annotation-card-header">
|
||||
<span
|
||||
className={`annotation-kind-badge ${
|
||||
entry.kind === "bookmark" ? "bookmark-badge" : "note-badge"
|
||||
}`}
|
||||
>
|
||||
{entry.kind === "bookmark" ? "Bookmark" : "Note"}
|
||||
</span>
|
||||
<span className="annotation-book-title">
|
||||
{entry.book_title}
|
||||
</span>
|
||||
<span className="annotation-page">p.{entry.page}</span>
|
||||
<span className="annotation-date">
|
||||
{new Date(entry.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{entry.location_text && (
|
||||
<blockquote className="annotation-quote">
|
||||
“{entry.location_text}”
|
||||
</blockquote>
|
||||
)}
|
||||
{entry.kind === "note" && entry.content && (
|
||||
<p className="note-content-text">{entry.content}</p>
|
||||
)}
|
||||
<div className="annotation-actions">
|
||||
{onNavigateToPage && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() =>
|
||||
onNavigateToPage(entry.book_id, entry.page)
|
||||
}
|
||||
>
|
||||
Go to page
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(entry)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from "react";
|
||||
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||
import type { Bookmark } from "@/types";
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookId?: string;
|
||||
onNavigateToPage?: (bookId: string, page: number) => void;
|
||||
}
|
||||
|
||||
export function BookmarkList({
|
||||
bookId,
|
||||
onNavigateToPage,
|
||||
}: BookmarkListProps): React.ReactElement {
|
||||
const { state, loadBookmarks, removeBookmark } = useAnnotations();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadBookmarks(bookId);
|
||||
}, [loadBookmarks, bookId]);
|
||||
|
||||
const handleDelete = async (id: string): Promise<void> => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await removeBookmark(id);
|
||||
} catch {
|
||||
// error handled by context
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.bookmarksLoading) {
|
||||
return <div className="annotations-loading">Loading bookmarks...</div>;
|
||||
}
|
||||
|
||||
if (state.bookmarks.length === 0) {
|
||||
return (
|
||||
<div className="annotations-empty">
|
||||
No bookmarks yet. Select a passage and add a bookmark while reading.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="annotations-list">
|
||||
{state.bookmarks.map((bookmark: Bookmark) => (
|
||||
<div key={bookmark.id} className="annotation-card">
|
||||
<div className="annotation-card-header">
|
||||
<span className="annotation-kind-badge bookmark-badge">
|
||||
Bookmark
|
||||
</span>
|
||||
<span className="annotation-page">
|
||||
Page {bookmark.page}
|
||||
</span>
|
||||
<span className="annotation-date">
|
||||
{new Date(bookmark.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{bookmark.location_text && (
|
||||
<blockquote className="annotation-quote">
|
||||
“{bookmark.location_text}”
|
||||
</blockquote>
|
||||
)}
|
||||
<div className="annotation-actions">
|
||||
{onNavigateToPage && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() =>
|
||||
onNavigateToPage(bookmark.book, bookmark.page)
|
||||
}
|
||||
>
|
||||
Go to page
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(bookmark.id)}
|
||||
disabled={deletingId === bookmark.id}
|
||||
>
|
||||
{deletingId === bookmark.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { AnnotationsDashboard } from "@/components/annotations";
|
||||
|
||||
interface BookmarksNotesPageProps {
|
||||
bookId?: string;
|
||||
}
|
||||
|
||||
export function BookmarksNotesPage({
|
||||
bookId,
|
||||
}: BookmarksNotesPageProps): React.ReactElement {
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Bookmarks & Notes</h2>
|
||||
<AnnotationsDashboard
|
||||
bookId={bookId}
|
||||
onNavigateToPage={(bookId, page) => {
|
||||
// Navigate to the book reader page at the specific page
|
||||
window.location.href = `/books/${bookId}?page=${page}`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from "react";
|
||||
import { useAnnotations } from "@/context/AnnotationsContext";
|
||||
import type { Note } from "@/types";
|
||||
|
||||
interface NoteListProps {
|
||||
bookId?: string;
|
||||
onNavigateToPage?: (bookId: string, page: number) => void;
|
||||
}
|
||||
|
||||
export function NoteList({
|
||||
bookId,
|
||||
onNavigateToPage,
|
||||
}: NoteListProps): React.ReactElement {
|
||||
const { state, loadNotes, editNote, removeNote } = useAnnotations();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState<string>("");
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadNotes(bookId);
|
||||
}, [loadNotes, bookId]);
|
||||
|
||||
const handleEdit = (note: Note): void => {
|
||||
setEditingId(note.id);
|
||||
setEditContent(note.content);
|
||||
};
|
||||
|
||||
const handleSave = async (id: string): Promise<void> => {
|
||||
setSavingId(id);
|
||||
try {
|
||||
await editNote(id, editContent.trim());
|
||||
setEditingId(null);
|
||||
} catch {
|
||||
// error handled by context
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = (): void => {
|
||||
setEditingId(null);
|
||||
setEditContent("");
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string): Promise<void> => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await removeNote(id);
|
||||
} catch {
|
||||
// error handled by context
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.notesLoading) {
|
||||
return <div className="annotations-loading">Loading notes...</div>;
|
||||
}
|
||||
|
||||
if (state.notes.length === 0) {
|
||||
return (
|
||||
<div className="annotations-empty">
|
||||
No notes yet. Select a passage and add a note while reading.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="annotations-list">
|
||||
{state.notes.map((note: Note) => (
|
||||
<div key={note.id} className="annotation-card">
|
||||
<div className="annotation-card-header">
|
||||
<span className="annotation-kind-badge note-badge">Note</span>
|
||||
<span className="annotation-page">Page {note.page}</span>
|
||||
<span className="annotation-date">
|
||||
{new Date(note.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{note.location_text && (
|
||||
<blockquote className="annotation-quote">
|
||||
“{note.location_text}”
|
||||
</blockquote>
|
||||
)}
|
||||
<div className="annotation-note-content">
|
||||
{editingId === note.id ? (
|
||||
<div className="note-edit-form">
|
||||
<textarea
|
||||
className="note-edit-textarea"
|
||||
value={editContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setEditContent(e.target.value)
|
||||
}
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="note-edit-actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleSave(note.id)}
|
||||
disabled={savingId === note.id || !editContent.trim()}
|
||||
>
|
||||
{savingId === note.id ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="note-content-text">{note.content}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="annotation-actions">
|
||||
{onNavigateToPage && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() =>
|
||||
onNavigateToPage(note.book, note.page)
|
||||
}
|
||||
>
|
||||
Go to page
|
||||
</button>
|
||||
)}
|
||||
{editingId !== note.id && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleEdit(note)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(note.id)}
|
||||
disabled={deletingId === note.id}
|
||||
>
|
||||
{deletingId === note.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { BookmarkList } from "./BookmarkList";
|
||||
export { NoteList } from "./NoteList";
|
||||
export { AddAnnotationForm } from "./AddAnnotationForm";
|
||||
export { AnnotationsDashboard } from "./AnnotationsDashboard";
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { AnnotationsProvider } from "@/context/AnnotationsContext";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Layout({
|
||||
children,
|
||||
title = "Cloud Reader",
|
||||
}: LayoutProps): React.ReactElement {
|
||||
return (
|
||||
<AnnotationsProvider>
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1 className="app-title">{title}</h1>
|
||||
<nav className="app-nav">
|
||||
<a href="/" className="nav-link">Home</a>
|
||||
<a href="/bookmarks-notes" className="nav-link">Bookmarks & Notes</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="app-main">{children}</main>
|
||||
</div>
|
||||
</AnnotationsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Layout } from "./Layout";
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Bookmark, Note, AnnotationEntry } from "@/types";
|
||||
import * as annotationsApi from "@/api/annotations";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AnnotationsState {
|
||||
bookmarks: Bookmark[];
|
||||
notes: Note[];
|
||||
bookmarksLoading: boolean;
|
||||
notesLoading: boolean;
|
||||
error: string | null;
|
||||
selectedBookId: string | null;
|
||||
}
|
||||
|
||||
const initialState: AnnotationsState = {
|
||||
bookmarks: [],
|
||||
notes: [],
|
||||
bookmarksLoading: false,
|
||||
notesLoading: false,
|
||||
error: null,
|
||||
selectedBookId: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AnnotationsAction =
|
||||
| { type: "FETCH_BOOKMARKS_START" }
|
||||
| { type: "FETCH_BOOKMARKS_SUCCESS"; payload: Bookmark[] }
|
||||
| { type: "FETCH_NOTES_START" }
|
||||
| { type: "FETCH_NOTES_SUCCESS"; payload: Note[] }
|
||||
| { type: "SET_ERROR"; payload: string }
|
||||
| { type: "CLEAR_ERROR" }
|
||||
| { type: "REMOVE_BOOKMARK"; payload: string }
|
||||
| { type: "REMOVE_NOTE"; payload: string }
|
||||
| { type: "UPDATE_NOTE"; payload: Note }
|
||||
| { type: "ADD_BOOKMARK"; payload: Bookmark }
|
||||
| { type: "ADD_NOTE"; payload: Note }
|
||||
| { type: "SET_SELECTED_BOOK"; payload: string | null };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function annotationsReducer(
|
||||
state: AnnotationsState,
|
||||
action: AnnotationsAction
|
||||
): AnnotationsState {
|
||||
switch (action.type) {
|
||||
case "FETCH_BOOKMARKS_START":
|
||||
return { ...state, bookmarksLoading: true, error: null };
|
||||
case "FETCH_BOOKMARKS_SUCCESS":
|
||||
return { ...state, bookmarks: action.payload, bookmarksLoading: false };
|
||||
case "FETCH_NOTES_START":
|
||||
return { ...state, notesLoading: true, error: null };
|
||||
case "FETCH_NOTES_SUCCESS":
|
||||
return { ...state, notes: action.payload, notesLoading: false };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.payload, bookmarksLoading: false, notesLoading: false };
|
||||
case "CLEAR_ERROR":
|
||||
return { ...state, error: null };
|
||||
case "REMOVE_BOOKMARK":
|
||||
return {
|
||||
...state,
|
||||
bookmarks: state.bookmarks.filter((b) => b.id !== action.payload),
|
||||
};
|
||||
case "REMOVE_NOTE":
|
||||
return {
|
||||
...state,
|
||||
notes: state.notes.filter((n) => n.id !== action.payload),
|
||||
};
|
||||
case "UPDATE_NOTE":
|
||||
return {
|
||||
...state,
|
||||
notes: state.notes.map((n) =>
|
||||
n.id === action.payload.id ? action.payload : n
|
||||
),
|
||||
};
|
||||
case "ADD_BOOKMARK":
|
||||
return {
|
||||
...state,
|
||||
bookmarks: [action.payload, ...state.bookmarks],
|
||||
};
|
||||
case "ADD_NOTE":
|
||||
return {
|
||||
...state,
|
||||
notes: [action.payload, ...state.notes],
|
||||
};
|
||||
case "SET_SELECTED_BOOK":
|
||||
return { ...state, selectedBookId: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AnnotationsContextValue {
|
||||
state: AnnotationsState;
|
||||
loadBookmarks: (bookId?: string) => Promise<void>;
|
||||
loadNotes: (bookId?: string) => Promise<void>;
|
||||
addBookmark: (data: { book: string; page: number; location_text?: string }) => Promise<Bookmark>;
|
||||
addNote: (data: { book: string; page: number; location_text?: string; content: string }) => Promise<Note>;
|
||||
editNote: (id: string, content: string) => Promise<Note>;
|
||||
removeBookmark: (id: string) => Promise<void>;
|
||||
removeNote: (id: string) => Promise<void>;
|
||||
setSelectedBook: (bookId: string | null) => void;
|
||||
/** Merged list of bookmarks + notes, sorted by created_at desc */
|
||||
mergedAnnotations: AnnotationEntry[];
|
||||
}
|
||||
|
||||
const AnnotationsContext = createContext<AnnotationsContextValue | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AnnotationsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
const [state, dispatch] = useReducer(annotationsReducer, initialState);
|
||||
|
||||
const loadBookmarks = useCallback(async (bookId?: string) => {
|
||||
dispatch({ type: "FETCH_BOOKMARKS_START" });
|
||||
try {
|
||||
const response = await annotationsApi.fetchBookmarks(bookId);
|
||||
dispatch({ type: "FETCH_BOOKMARKS_SUCCESS", payload: response.results });
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: err instanceof Error ? err.message : "Failed to load bookmarks",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNotes = useCallback(async (bookId?: string) => {
|
||||
dispatch({ type: "FETCH_NOTES_START" });
|
||||
try {
|
||||
const response = await annotationsApi.fetchNotes(bookId);
|
||||
dispatch({ type: "FETCH_NOTES_SUCCESS", payload: response.results });
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: "SET_ERROR",
|
||||
payload: err instanceof Error ? err.message : "Failed to load notes",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addBookmark = useCallback(
|
||||
async (data: {
|
||||
book: string;
|
||||
page: number;
|
||||
location_text?: string;
|
||||
}): Promise<Bookmark> => {
|
||||
const bookmark = await annotationsApi.createBookmark(data);
|
||||
dispatch({ type: "ADD_BOOKMARK", payload: bookmark });
|
||||
return bookmark;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addNote = useCallback(
|
||||
async (data: {
|
||||
book: string;
|
||||
page: number;
|
||||
location_text?: string;
|
||||
content: string;
|
||||
}): Promise<Note> => {
|
||||
const note = await annotationsApi.createNote(data);
|
||||
dispatch({ type: "ADD_NOTE", payload: note });
|
||||
return note;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const editNote = useCallback(
|
||||
async (id: string, content: string): Promise<Note> => {
|
||||
const updated = await annotationsApi.updateNote(id, { content });
|
||||
dispatch({ type: "UPDATE_NOTE", payload: updated });
|
||||
return updated;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeBookmark = useCallback(async (id: string) => {
|
||||
await annotationsApi.deleteBookmark(id);
|
||||
dispatch({ type: "REMOVE_BOOKMARK", payload: id });
|
||||
}, []);
|
||||
|
||||
const removeNote = useCallback(async (id: string) => {
|
||||
await annotationsApi.deleteNote(id);
|
||||
dispatch({ type: "REMOVE_NOTE", payload: id });
|
||||
}, []);
|
||||
|
||||
const setSelectedBook = useCallback((bookId: string | null) => {
|
||||
dispatch({ type: "SET_SELECTED_BOOK", payload: bookId });
|
||||
}, []);
|
||||
|
||||
// Build merged annotations list sorted by created_at desc
|
||||
const mergedAnnotations: AnnotationEntry[] = [
|
||||
...state.bookmarks.map(
|
||||
(b): AnnotationEntry => ({
|
||||
id: b.id,
|
||||
kind: "bookmark",
|
||||
book_title: b.book_title,
|
||||
book_id: b.book,
|
||||
page: b.page,
|
||||
location_text: b.location_text,
|
||||
created_at: b.created_at,
|
||||
updated_at: b.updated_at,
|
||||
})
|
||||
),
|
||||
...state.notes.map(
|
||||
(n): AnnotationEntry => ({
|
||||
id: n.id,
|
||||
kind: "note",
|
||||
book_title: n.book_title,
|
||||
book_id: n.book,
|
||||
page: n.page,
|
||||
location_text: n.location_text,
|
||||
content: n.content,
|
||||
created_at: n.created_at,
|
||||
updated_at: n.updated_at,
|
||||
})
|
||||
),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
|
||||
const value: AnnotationsContextValue = {
|
||||
state,
|
||||
loadBookmarks,
|
||||
loadNotes,
|
||||
addBookmark,
|
||||
addNote,
|
||||
editNote,
|
||||
removeBookmark,
|
||||
removeNote,
|
||||
setSelectedBook,
|
||||
mergedAnnotations,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnnotationsContext.Provider value={value}>
|
||||
{children}
|
||||
</AnnotationsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAnnotations(): AnnotationsContextValue {
|
||||
const context = useContext(AnnotationsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useAnnotations must be used within an AnnotationsProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { usePaginatedQuery } from "./usePaginatedQuery";
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { PaginatedResponse } from "@/types";
|
||||
|
||||
interface UsePaginatedQueryOptions<T> {
|
||||
fetchFn: (cursor?: string) => Promise<PaginatedResponse<T>>;
|
||||
}
|
||||
|
||||
interface UsePaginatedQueryResult<T> {
|
||||
items: T[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
hasMore: boolean;
|
||||
loadMore: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for paginated list fetching with infinite scroll support.
|
||||
*/
|
||||
export function usePaginatedQuery<T>({
|
||||
fetchFn,
|
||||
}: UsePaginatedQueryOptions<T>): UsePaginatedQueryResult<T> {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetchFn();
|
||||
setItems(response.results);
|
||||
setNextCursor(response.next);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchFn]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
try {
|
||||
const response = await fetchFn(nextCursor);
|
||||
setItems((prev) => [...prev, ...response.results]);
|
||||
setNextCursor(response.next);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load more");
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}, [nextCursor, fetchFn]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
hasMore: nextCursor !== null,
|
||||
loadMore,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { BookmarksNotesPage } from "@/components/annotations/BookmarksNotesPage";
|
||||
import "./styles.css";
|
||||
|
||||
function App(): React.ReactElement {
|
||||
const path = window.location.pathname;
|
||||
|
||||
// Simple client-side routing
|
||||
if (path.startsWith("/books/") && path.includes("bookmarks-notes")) {
|
||||
// /books/:id/bookmarks-notes
|
||||
const bookId = path.split("/")[2];
|
||||
return (
|
||||
<Layout title="Cloud Reader">
|
||||
<BookmarksNotesPage bookId={bookId} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (path === "/bookmarks-notes" || path === "/bookmarks-notes/") {
|
||||
return (
|
||||
<Layout title="Cloud Reader">
|
||||
<BookmarksNotesPage />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: landing page
|
||||
return (
|
||||
<Layout title="Cloud Reader">
|
||||
<div className="page">
|
||||
<h2>Welcome to Cloud Reader</h2>
|
||||
<p>Your personal e-book reader with cross-device sync.</p>
|
||||
<div className="quick-links">
|
||||
<a href="/bookmarks-notes" className="card-link">
|
||||
<h3>Bookmarks & Notes</h3>
|
||||
<p>View and manage all your annotations</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (rootElement) {
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
Cloud Reader – Global Styles
|
||||
Minimal, clean design system for the bookmark & notes management UI.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* Reset & base */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg: #f8f9fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-primary: #4f46e5;
|
||||
--color-primary-hover: #4338ca;
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-hover: #dc2626;
|
||||
--color-secondary: #6b7280;
|
||||
--color-secondary-hover: #4b5563;
|
||||
--color-text: #1f2937;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-border: #e5e7eb;
|
||||
--color-quote-bg: #f3f4f6;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* App container */
|
||||
.app-container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.app-main {
|
||||
min-height: 70vh;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.page h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Quick links */
|
||||
.quick-links {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: block;
|
||||
padding: 1.25rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card-link:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.card-link h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-link p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--color-danger-hover);
|
||||
border-color: var(--color-danger-hover);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Annotation list */
|
||||
.annotations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.annotation-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.annotation-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.annotation-kind-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.bookmark-badge {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.note-badge {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.annotation-book-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.annotation-page {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.annotation-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.annotation-quote {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-quote-bg);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-style: italic;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.note-content-text {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
margin: 0.5rem 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.annotation-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Loading & empty states */
|
||||
.annotations-loading,
|
||||
.annotations-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* Dashboard summary */
|
||||
.annotations-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.summary-separator {
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Add annotation modal */
|
||||
.add-annotation-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.add-annotation-modal {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.add-annotation-modal h3 {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Annotation form */
|
||||
.mode-selector {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.annotation-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font);
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Note edit inline */
|
||||
.note-edit-form {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.note-edit-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font);
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.note-edit-textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
|
||||
.note-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.annotation-card-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.annotation-date {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("Annotations types", () => {
|
||||
it("validates AnnotationKind union type", () => {
|
||||
const bookmark: "bookmark" = "bookmark";
|
||||
const note: "note" = "note";
|
||||
expect(bookmark).toBe("bookmark");
|
||||
expect(note).toBe("note");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/** Core domain types for Cloud Reader */
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
total_pages: number;
|
||||
cover_image: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BookSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
total_pages: number;
|
||||
cover_image: string;
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: string;
|
||||
book: string;
|
||||
book_title: string;
|
||||
page: number;
|
||||
location_text: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
book: string;
|
||||
book_title: string;
|
||||
page: number;
|
||||
location_text: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Payload for creating a new bookmark */
|
||||
export interface CreateBookmarkPayload {
|
||||
book: string;
|
||||
page: number;
|
||||
location_text?: string;
|
||||
}
|
||||
|
||||
/** Payload for creating a new note */
|
||||
export interface CreateNotePayload {
|
||||
book: string;
|
||||
page: number;
|
||||
location_text?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Payload for updating a note (only content is mutable) */
|
||||
export interface UpdateNotePayload {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Paginated API response shape */
|
||||
export interface PaginatedResponse<T> {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
/** Auth token response */
|
||||
export interface TokenResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
/** Unified annotation type for the combined list view */
|
||||
export type AnnotationKind = "bookmark" | "note";
|
||||
|
||||
export interface AnnotationEntry {
|
||||
id: string;
|
||||
kind: AnnotationKind;
|
||||
book_title: string;
|
||||
book_id: string;
|
||||
page: number;
|
||||
location_text: string;
|
||||
content?: string; // notes only
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user