feat: uv config other feats

- add uv configuration for the backend
- update frontend to make auth work
- add new auth endpoints
- add bookmars feat
- add reader feat
This commit is contained in:
2026-06-03 22:06:01 -05:00
parent 730c748f5f
commit 6b4c0c43f8
137 changed files with 20319 additions and 2340 deletions
+1
View File
@@ -0,0 +1 @@
import{X as j,h as S,r as d,j as e}from"./index-BQiEVoRj.js";import{b as B}from"./books-BG7wMiRx.js";import{g as v}from"./errors-bLl0IgZb.js";function R(){const{t:o}=j(),s=S(),[a,p]=d.useState(""),[c,b]=d.useState(""),[n,u]=d.useState(null),[r,f]=d.useState(!1),[x,i]=d.useState(null),y=t=>{var g,h;const l=((g=t.target.files)==null?void 0:g[0])??null;if(l){const m=(h=l.name.split(".").pop())==null?void 0:h.toLowerCase();if(m!=="epub"&&m!=="pdf"){i(o("addBook.fileTypeError")),u(null);return}u(l),i(null),a||p(l.name.replace(/\.(epub|pdf)$/i,"").slice(0,512))}},k=async t=>{if(t.preventDefault(),!n||!a.trim()){i(o("addBook.requiredError"));return}f(!0),i(null);try{await B.uploadEBook(n,a.trim(),c.trim()),s("/")}catch(l){i(v(l,o("addBook.uploadFailed")))}finally{f(!1)}};return e.jsxs("div",{style:{maxWidth:500,margin:"0 auto",padding:16,minHeight:"100vh",background:"#f8f9fa"},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12,marginBottom:24,padding:"16px 0",borderBottom:"1px solid #eee"},children:[e.jsxs("button",{onClick:()=>s("/"),style:{padding:"8px 16px",borderRadius:6,border:"1px solid #ddd",background:"#fff",cursor:"pointer",fontSize:14},children:["← ",o("common.back")]}),e.jsx("h1",{style:{fontSize:24,fontWeight:700,color:"#1a1a2e",margin:0},children:o("addBook.title")})]}),e.jsxs("form",{onSubmit:k,style:{display:"flex",flexDirection:"column",gap:20},children:[x&&e.jsx("div",{style:{background:"#fde8e8",padding:12,borderRadius:6,color:"#e74c3c",fontSize:14},children:x}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.fileLabel")}),e.jsx("input",{type:"file",accept:".epub,.pdf",onChange:y,style:{padding:"10px 0"}}),n&&e.jsx("p",{style:{fontSize:13,color:"#666",marginTop:4},children:o("addBook.selected",{name:n.name})})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.titleLabel")}),e.jsx("input",{type:"text",value:a,onChange:t=>p(t.target.value),placeholder:o("addBook.titlePlaceholder"),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:o("addBook.authorLabel")}),e.jsx("input",{type:"text",value:c,onChange:t=>b(t.target.value),placeholder:o("addBook.authorPlaceholder"),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),e.jsx("button",{type:"submit",disabled:r,style:{padding:"12px 24px",borderRadius:8,border:"none",background:"#1a1a2e",color:"#fff",fontSize:16,fontWeight:600,cursor:"pointer",opacity:r?.6:1,marginTop:8},children:o(r?"addBook.uploading":"addBook.upload")})]})]})}export{R as AddBookPage};
+1
View File
@@ -0,0 +1 @@
import{j as t,X as v,r as o,e as w,h as z}from"./index-BQiEVoRj.js";import{g as A}from"./errors-bLl0IgZb.js";function h({isLogin:n,onToggle:p}){const{t:e}=v(),[i,g]=o.useState(""),[a,f]=o.useState(""),[d,x]=o.useState(""),[c,s]=o.useState(null),[l,u]=o.useState(!1),{login:m,register:y}=w(),b=z(),j=async r=>{if(r.preventDefault(),s(null),!n&&a!==d){s(e("auth.passwordsMismatch"));return}u(!0);try{n?await m(i,a):await y(i,a),b("/")}catch(S){s(A(S,e(n?"auth.loginFailed":"auth.registrationFailed")))}finally{u(!1)}};return t.jsx("div",{style:{display:"flex",justifyContent:"center",alignItems:"center",minHeight:"100vh",background:"#f8f9fa",padding:16},children:t.jsxs("div",{style:{width:"100%",maxWidth:400,background:"#fff",borderRadius:12,padding:32,boxShadow:"0 2px 16px rgba(0,0,0,0.08)"},children:[t.jsx("h1",{style:{fontSize:28,fontWeight:700,color:"#1a1a2e",textAlign:"center",marginBottom:4},children:e("common.appName")}),t.jsx("h2",{style:{fontSize:16,color:"#888",textAlign:"center",marginBottom:24,fontWeight:400},children:e(n?"auth.signIn":"auth.createAccount")}),t.jsxs("form",{onSubmit:j,style:{display:"flex",flexDirection:"column",gap:16},children:[c&&t.jsx("div",{style:{background:"#fde8e8",padding:12,borderRadius:6,color:"#e74c3c",fontSize:14},children:c}),t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.email")}),t.jsx("input",{type:"email",value:i,onChange:r=>g(r.target.value),placeholder:e("auth.emailPlaceholder"),required:!0,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.password")}),t.jsx("input",{type:"password",value:a,onChange:r=>f(r.target.value),placeholder:e("auth.passwordPlaceholder"),required:!0,minLength:8,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),!n&&t.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:6},children:[t.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:e("auth.confirmPassword")}),t.jsx("input",{type:"password",value:d,onChange:r=>x(r.target.value),placeholder:e("auth.confirmPasswordPlaceholder"),required:!0,minLength:8,style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,outline:"none"}})]}),t.jsx("button",{type:"submit",disabled:l,style:{padding:"12px 24px",borderRadius:8,border:"none",background:"#1a1a2e",color:"#fff",fontSize:16,fontWeight:600,cursor:"pointer",opacity:l?.6:1,marginTop:8},children:e(l?n?"auth.signingIn":"auth.creatingAccount":n?"auth.signIn":"auth.createAccount")})]}),t.jsxs("p",{style:{textAlign:"center",marginTop:20,color:"#888",fontSize:14},children:[n?e("auth.noAccount")+" ":e("auth.hasAccount")+" ",t.jsx("button",{onClick:p,style:{background:"none",border:"none",color:"#1a1a2e",fontWeight:600,cursor:"pointer",fontSize:14,textDecoration:"underline"},children:e(n?"auth.register":"auth.signIn")})]})]})})}function W({onToggle:n}){return t.jsx(h,{isLogin:!0,onToggle:n})}function k({onToggle:n}){return t.jsx(h,{isLogin:!1,onToggle:n})}export{W as LoginPage,k as RegisterPage};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{X as m,u,r as l,j as e,h as g,i as j}from"./index-BQiEVoRj.js";function N({ebookIdFilter:n,onGoToPassage:i}){const{t:s}=m(),{markersByBook:r,loadBookmarks:c,removeBookmark:k,state:h}=u(),[p,x]=l.useState({});l.useEffect(()=>{c(n)},[c,n]);const d=l.useMemo(()=>{if(!n)return r;const a=Number(n);return r.filter(o=>o.ebookId===a)},[r,n]);if(h.bookmarksLoading)return e.jsx("div",{className:"annotations-loading",children:s("annotations.loading")});if(d.length===0)return e.jsxs("div",{className:"annotations-empty",children:[e.jsx("p",{children:s("annotations.empty")}),e.jsx("p",{style:{fontSize:14,color:"#6b7280",marginTop:8},children:s("annotations.selectTextHint")})]});const b=a=>{x(o=>({...o,[a]:!o[a]}))};return e.jsx("div",{className:"marker-books-list",children:d.map(a=>{const o=p[a.ebookId]??!1;return e.jsxs("section",{className:"marker-book-group",children:[e.jsxs("button",{type:"button",className:"marker-book-header",onClick:()=>b(a.ebookId),children:[e.jsx("span",{className:"marker-book-title",children:a.ebookTitle}),e.jsx("span",{className:"marker-book-count",children:s("annotations.markerCount",{count:String(a.markers.length)})}),e.jsx("span",{className:"marker-book-chevron",children:o?"▸":"▾"})]}),!o&&e.jsx("ul",{className:"marker-thread-list",children:a.markers.map(t=>e.jsxs("li",{className:"marker-thread",children:[e.jsx("div",{className:"marker-thread-meta",children:t.chapter_title?e.jsx("span",{className:"marker-thread-chapter",children:t.chapter_title}):e.jsx("span",{className:"marker-thread-chapter",children:s("annotations.chapterIndex",{index:String(t.chapter_index+1)})})}),t.location_text&&e.jsxs("blockquote",{className:"annotation-quote marker-thread-passage",children:["“",t.location_text,"”"]}),t.content?e.jsx("p",{className:"marker-thread-thought marker-thread-reply",children:t.content}):e.jsx("span",{className:"annotation-kind-badge bookmark-badge",children:s("annotations.bookmarkOnly")}),e.jsxs("div",{className:"annotation-actions",children:[e.jsx("button",{type:"button",className:"btn btn-sm",onClick:()=>i(t.ebook_id,t.epub_cfi),children:s("annotations.goToPassage")}),e.jsx("button",{type:"button",className:"btn btn-sm btn-danger",onClick:()=>k(t.id),children:s("common.delete")})]})]},t.id))})]},a.ebookId)})})}function y(){const{t:n}=m(),i=g(),{bookId:s}=j();return e.jsxs("div",{className:"page bookmarks-notes-page",style:{maxWidth:720,margin:"0 auto",padding:16},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12,marginBottom:24},children:[e.jsxs("button",{type:"button",onClick:()=>i("/"),style:{padding:"8px 16px",borderRadius:6,border:"1px solid #ddd",background:"#fff",cursor:"pointer",fontSize:14},children:["← ",n("common.back")]}),e.jsx("h2",{style:{margin:0,fontSize:22,fontWeight:700},children:n("annotations.title")})]}),e.jsx(N,{ebookIdFilter:s,onGoToPassage:(r,c)=>{i(`/read/${r}`,{state:{epubLocation:c}})}})]})}export{y as BookmarksNotesPage};
+1
View File
@@ -0,0 +1 @@
._container_1fxkh_1{position:absolute;top:100%;left:0;right:0;z-index:100;background:#fff;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 10px 10px;box-shadow:0 8px 24px #0000001f;max-height:320px;overflow-y:auto}._infoText_1fxkh_31{padding:12px 16px;color:#9ca3af;font-size:13px}._suggestionItem_1fxkh_43{display:flex;align-items:center;gap:12px;padding:10px 16px;cursor:pointer;border-bottom:1px solid #f3f4f6;min-height:44px}._suggestionItem_1fxkh_43:hover{background:#f9fafb}._coverImage_1fxkh_71{width:32px;height:48px;object-fit:cover;border-radius:4px}._coverPlaceholder_1fxkh_85{font-size:20px;flex-shrink:0}._bookInfo_1fxkh_95{min-width:0}._bookTitle_1fxkh_103{font-size:14px;font-weight:600;color:#1f2937;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}._bookAuthor_1fxkh_121{font-size:12px;color:#6b7280;margin-top:2px}._menu_ekpcj_1{position:fixed;z-index:9999;min-width:180px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 4px 20px #0000001f;padding:4px 0;overflow:hidden}._item_ekpcj_25{display:block;width:100%;padding:10px 16px;border:none;background:transparent;text-align:left;font-size:14px;color:#374151;cursor:pointer;transition:background .1s}._item_ekpcj_25:hover:not(:disabled){background:#f3f4f6}._item_ekpcj_25:disabled{opacity:.5;cursor:not-allowed}._itemDanger_ekpcj_69{color:#dc2626}._itemDanger_ekpcj_69:hover:not(:disabled){background:#fef2f2}._itemLoading_ekpcj_85{color:#6b7280}._separator_ekpcj_93{height:1px;background:#e5e7eb;margin:4px 0}._bookCard_11p5a_1{background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px #0000000f;cursor:pointer;transition:transform .15s,box-shadow .15s}._bookCard_11p5a_1:hover{transform:translateY(-2px);box-shadow:0 4px 16px #0000001a}._coverProgressBar_11p5a_29{position:absolute;left:0;right:0;bottom:0;height:4px;background:#0000001f}._coverProgressFill_11p5a_47{height:100%;background:#16a34a;transition:width .2s ease}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{X as c,j as e}from"./index-BQiEVoRj.js";function h({bookTitle:s,chapterTitle:l,progress:a,onBack:t,onToggleToc:n,onToggleSettings:o,onToggleMarkers:i}){const{t:r}=c(),d=(a==null?void 0:a.percentage)??0;return e.jsxs(e.Fragment,{children:[e.jsxs("header",{className:"reader-top-bar",children:[t?e.jsx("button",{type:"button",className:"reader-bar-btn",onClick:t,"aria-label":r("reader.backToLibraryAria"),children:e.jsx("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:e.jsx("path",{d:"M20 12H4M10 18l-6-6 6-6"})})}):e.jsx("button",{type:"button",className:"reader-bar-btn",onClick:n,"aria-label":r("reader.tocAria"),children:e.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("line",{x1:"3",y1:"6",x2:"21",y2:"6"}),e.jsx("line",{x1:"3",y1:"12",x2:"21",y2:"12"}),e.jsx("line",{x1:"3",y1:"18",x2:"21",y2:"18"})]})}),e.jsxs("div",{className:"reader-bar-title",children:[e.jsx("span",{className:"reader-bar-book",children:s}),e.jsx("span",{className:"reader-bar-chapter",children:l})]}),e.jsxs("div",{className:"reader-bar-actions",children:[t&&e.jsx("button",{type:"button",className:"reader-bar-btn",onClick:n,"aria-label":r("reader.tocAria"),children:e.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("line",{x1:"3",y1:"6",x2:"21",y2:"6"}),e.jsx("line",{x1:"3",y1:"12",x2:"21",y2:"12"}),e.jsx("line",{x1:"3",y1:"18",x2:"21",y2:"18"})]})}),i&&e.jsx("button",{type:"button",className:"reader-bar-btn",onClick:i,"aria-label":r("annotations.inBookPanel"),children:e.jsx("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:e.jsx("path",{d:"M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"})})}),e.jsx("button",{type:"button",className:"reader-bar-btn",onClick:o,"aria-label":r("reader.settingsAria"),children:e.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("circle",{cx:"12",cy:"12",r:"3"}),e.jsx("path",{d:"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"})]})})]})]}),e.jsx("div",{className:"reader-progress-bar",children:e.jsx("div",{className:"reader-progress-fill",style:{width:`${Math.min(d,100)}%`}})})]})}export{h as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{X as m,j as e}from"./index-BQiEVoRj.js";const g=[{value:"sepia",labelKey:"reader.themeSepia"},{value:"dark",labelKey:"reader.themeDark"},{value:"light",labelKey:"reader.themeLight"},{value:"paper",labelKey:"reader.themePaper"}],u=[{value:"sans-serif",labelKey:"reader.fontSans"},{value:"serif",labelKey:"reader.fontSerif"},{value:"monospace",labelKey:"reader.fontMonospace"}],x=[{value:"auto",labelKey:"reader.orientationAuto"},{value:"portrait",labelKey:"reader.orientationPortrait"},{value:"landscape",labelKey:"reader.orientationLandscape"}];function v({settings:t,isOpen:c,onClose:d,onUpdate:o,onFlush:h}){const{t:s}=m(),n=()=>{h(),d()},i=(a,r)=>{o({[a]:r},"debounced")},l=(a,r)=>{o({[a]:r},"immediate")};return e.jsxs(e.Fragment,{children:[c&&e.jsx("div",{className:"settings-overlay",onClick:n,onKeyDown:a=>{a.key==="Escape"&&n()},role:"presentation"}),e.jsxs("aside",{className:`settings-drawer ${c?"settings-drawer--open":""}`,children:[e.jsxs("div",{className:"settings-header",children:[e.jsx("h2",{className:"settings-title",children:s("reader.settingsTitle")}),e.jsx("button",{type:"button",className:"settings-close-btn",onClick:n,"aria-label":s("reader.closeSettingsAria"),children:e.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("line",{x1:"18",y1:"6",x2:"6",y2:"18"}),e.jsx("line",{x1:"6",y1:"6",x2:"18",y2:"18"})]})})]}),e.jsxs("div",{className:"settings-body",children:[e.jsxs("section",{className:"settings-section",children:[e.jsx("h3",{className:"settings-section-title",children:s("reader.theme")}),e.jsx("div",{className:"theme-grid",children:g.map(a=>e.jsx("button",{type:"button",className:`theme-btn ${t.theme===a.value?"theme-btn--active":""}`,onClick:()=>l("theme",a.value),children:s(a.labelKey)},a.value))})]}),e.jsxs("section",{className:"settings-section",children:[e.jsx("h3",{className:"settings-section-title",children:s("reader.font")}),e.jsx("div",{className:"font-grid",children:u.map(a=>e.jsx("button",{type:"button",className:`font-btn ${t.font_family===a.value?"font-btn--active":""}`,onClick:()=>l("font_family",a.value),children:s(a.labelKey)},a.value))})]}),e.jsxs("section",{className:"settings-section",children:[e.jsx("h3",{className:"settings-section-title",children:s("reader.fontSize",{size:String(t.font_size)})}),e.jsx("input",{type:"range",min:"12",max:"32",value:t.font_size,onChange:a=>i("font_size",Number(a.target.value)),className:"settings-slider","aria-label":s("reader.fontSizeAria")})]}),e.jsxs("section",{className:"settings-section",children:[e.jsx("h3",{className:"settings-section-title",children:s("reader.lineHeight",{value:t.line_height.toFixed(1)})}),e.jsx("input",{type:"range",min:"1.2",max:"2.0",step:"0.1",value:t.line_height,onChange:a=>i("line_height",Number(a.target.value)),className:"settings-slider","aria-label":s("reader.lineHeightAria")})]}),e.jsxs("section",{className:"settings-section",children:[e.jsx("h3",{className:"settings-section-title",children:s("reader.margins",{value:String(t.margin_width)})}),e.jsx("input",{type:"range",min:"8",max:"48",value:t.margin_width,onChange:a=>i("margin_width",Number(a.target.value)),className:"settings-slider","aria-label":s("reader.marginWidthAria")})]}),e.jsxs("section",{className:"settings-section",children:[e.jsx("h3",{className:"settings-section-title",children:s("reader.brightness",{value:String(t.brightness)})}),e.jsx("input",{type:"range",min:"0",max:"100",value:t.brightness,onChange:a=>i("brightness",Number(a.target.value)),className:"settings-slider","aria-label":s("reader.brightnessAria")})]}),e.jsxs("section",{className:"settings-section",children:[e.jsx("h3",{className:"settings-section-title",children:s("reader.orientation")}),e.jsx("div",{className:"orientation-grid",children:x.map(a=>e.jsx("button",{type:"button",className:`orientation-btn ${t.orientation_lock===a.value?"orientation-btn--active":""}`,onClick:()=>l("orientation_lock",a.value),children:s(a.labelKey)},a.value))})]})]})]})]})}export{v as default};
+1
View File
@@ -0,0 +1 @@
import{X as j,h as m,r as i,j as e}from"./index-BQiEVoRj.js";import{b as g}from"./books-BG7wMiRx.js";const S=[{value:"#ffffff",labelKey:"settings.bgWhite"},{value:"#f4e4c1",labelKey:"settings.bgSepia"},{value:"#1a1a2e",labelKey:"settings.bgDark"},{value:"#c7edcc",labelKey:"settings.bgGreen"}];function W(){const{t,language:u,setLanguage:p}=j(),x=m(),[s,a]=i.useState(null),[h,y]=i.useState(!0),[o,c]=i.useState(!1),[f,r]=i.useState(null),[v,d]=i.useState(!1);i.useEffect(()=>{(async()=>{try{const l=await g.getSettings();a(l)}catch(l){r(l instanceof Error?l.message:t("settings.loadFailed"))}finally{y(!1)}})()},[t]);const b=async()=>{if(s){c(!0),r(null),d(!1);try{await g.updateSettings(s),d(!0),setTimeout(()=>d(!1),2e3)}catch(n){r(n instanceof Error?n.message:t("settings.saveFailed"))}finally{c(!1)}}};return h?e.jsx("div",{style:{maxWidth:500,margin:"0 auto",padding:16,minHeight:"100vh",background:"#f8f9fa"},children:e.jsx("p",{children:t("settings.loading")})}):e.jsxs("div",{style:{maxWidth:500,margin:"0 auto",padding:16,minHeight:"100vh",background:"#f8f9fa"},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12,marginBottom:24,padding:"16px 0",borderBottom:"1px solid #eee"},children:[e.jsxs("button",{onClick:()=>x("/"),style:{padding:"8px 16px",borderRadius:6,border:"1px solid #ddd",background:"#fff",cursor:"pointer",fontSize:14},children:["← ",t("common.back")]}),e.jsx("h1",{style:{fontSize:24,fontWeight:700,color:"#1a1a2e",margin:0},children:t("settings.title")})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:24},children:[f&&e.jsx("div",{style:{background:"#fde8e8",padding:12,borderRadius:6,color:"#e74c3c",fontSize:14},children:f}),v&&e.jsx("div",{style:{background:"#d4edda",padding:12,borderRadius:6,color:"#155724",fontSize:14},children:t("settings.saved")}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:8},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:t("settings.uiLanguage")}),e.jsxs("select",{value:u,onChange:n=>p(n.target.value),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,background:"#fff",outline:"none"},children:[e.jsx("option",{value:"en-US",children:t("settings.uiLanguageEn")}),e.jsx("option",{value:"es-ES",children:t("settings.uiLanguageEs")})]})]}),s&&e.jsxs(e.Fragment,{children:[e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:8},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:t("settings.fontSize",{size:String(s.font_size)})}),e.jsx("input",{type:"range",min:12,max:36,value:s.font_size,onChange:n=>a({...s,font_size:Number(n.target.value)}),style:{width:"100%",cursor:"pointer"}}),e.jsxs("div",{style:{display:"flex",justifyContent:"space-between",fontSize:12,color:"#999"},children:[e.jsx("span",{children:"12px"}),e.jsx("span",{children:"36px"})]})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:8},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:t("settings.fontStyle")}),e.jsxs("select",{value:s.font_style,onChange:n=>a({...s,font_style:n.target.value}),style:{padding:"10px 12px",borderRadius:6,border:"1px solid #ddd",fontSize:16,background:"#fff",outline:"none"},children:[e.jsx("option",{value:"sans-serif",children:t("settings.fontSans")}),e.jsx("option",{value:"serif",children:t("settings.fontSerif")}),e.jsx("option",{value:"monospace",children:t("settings.fontMonospace")})]})]}),e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:8},children:[e.jsx("label",{style:{fontSize:14,fontWeight:600,color:"#555"},children:t("settings.backgroundColor")}),e.jsx("div",{style:{display:"flex",gap:12,flexWrap:"wrap"},children:S.map(n=>e.jsx("button",{onClick:()=>a({...s,background_color:n.value}),style:{width:48,height:48,borderRadius:"50%",cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center",background:n.value,border:s.background_color===n.value?"3px solid #1a1a2e":"3px solid #ddd"},title:t(n.labelKey),children:s.background_color===n.value&&e.jsx("span",{style:{fontSize:20,fontWeight:700,color:n.value==="#ffffff"||n.value==="#f4e4c1"?"#1a1a2e":"#fff"},children:"✓"})},n.value))})]})]}),e.jsx("button",{onClick:b,disabled:o,style:{padding:"12px 24px",borderRadius:8,border:"none",background:"#1a1a2e",color:"#fff",fontSize:16,fontWeight:600,cursor:"pointer",opacity:o?.6:1,marginTop:8},children:t(o?"common.saving":"settings.saveSettings")})]})]})}export{W as SettingsPage};
+1
View File
@@ -0,0 +1 @@
import{X as i,j as e}from"./index-BQiEVoRj.js";function o({item:s,depth:a,onNavigate:t}){var c;return e.jsxs(e.Fragment,{children:[e.jsx("button",{type:"button",className:"toc-item",style:{paddingLeft:`${16+a*14}px`},onClick:()=>t(s.href),children:e.jsx("span",{className:"toc-item-title",children:s.label})}),(c=s.subitems)==null?void 0:c.map(r=>e.jsx(o,{item:r,depth:a+1,onNavigate:t},r.href))]})}function x({items:s,isOpen:a,onClose:t,onNavigate:c}){const{t:r}=i(),l=n=>{c(n),t()};return e.jsxs(e.Fragment,{children:[a&&e.jsx("div",{className:"toc-overlay",onClick:t,onKeyDown:n=>{n.key==="Escape"&&t()},role:"presentation"}),e.jsxs("aside",{className:`toc-drawer ${a?"toc-drawer--open":""}`,children:[e.jsxs("div",{className:"toc-header",children:[e.jsx("h2",{className:"toc-title",children:r("reader.tocTitle")}),e.jsx("button",{type:"button",className:"toc-close-btn",onClick:t,"aria-label":r("reader.closeTocAria"),children:e.jsxs("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",children:[e.jsx("line",{x1:"18",y1:"6",x2:"6",y2:"18"}),e.jsx("line",{x1:"6",y1:"6",x2:"18",y2:"18"})]})})]}),e.jsx("nav",{className:"toc-list",children:s.length===0?e.jsx("p",{className:"toc-empty",children:r("reader.tocEmpty")}):s.map(n=>e.jsx(o,{item:n,depth:0,onNavigate:l},n.href))})]})]})}export{x as default};
+1
View File
@@ -0,0 +1 @@
import{a}from"./index-BQiEVoRj.js";const i={async getEBooks(){const{data:t}=await a.get("/books/ebooks/");return Array.isArray(t)?t:t.results??[]},async getEBook(t){const{data:o}=await a.get(`/books/ebooks/${t}/`);return o},async getEpubFile(t){const{data:o}=await a.get(`/books/ebooks/${t}/file/`,{responseType:"blob"});return o},async searchBooks(t={}){const{data:o}=await a.get("/books/",{params:t});return Array.isArray(o)?{count:o.length,results:o}:{count:o.count??0,results:o.results??[]}},async getBook(t){const{data:o}=await a.get(`/books/${t}/`);return o},async getGenres(){const{data:t}=await a.get("/books/genres/");return t},async getAuthors(){const{data:t}=await a.get("/books/authors/");return t},async uploadEBook(t,o,s,n){const e=new FormData;e.append("file",t),e.append("title",o),s&&e.append("author",s),n&&e.append("cover_image",n);const{data:r}=await a.post("/books/ebooks/",e,{headers:{"Content-Type":"multipart/form-data"}});return r},async deleteEBook(t){await a.delete(`/books/ebooks/${t}/`)},async enrichEBookMetadata(t){const{data:o}=await a.post(`/books/ebooks/${t}/enrich-metadata/`);return o},async processEBook(t){const{data:o}=await a.post(`/books/ebooks/${t}/process/`);return o},async getToc(t){const{data:o}=await a.get(`/books/ebooks/${t}/toc/`);return o},async getContent(t,o){const{data:s}=await a.get(`/books/ebooks/${t}/content/?page=${o}`);return s},async getProgress(t){const{data:o}=await a.get(`/books/ebooks/${t}/progress/`);return o},async updateProgress(t,o){const{data:s}=await a.patch(`/books/ebooks/${t}/progress/`,o);return s},async getSettings(){const{data:t}=await a.get("/books/settings/");return t},async updateSettings(t){const{data:o}=await a.patch("/books/settings/",t);return o}};export{i as b};
+1
View File
@@ -0,0 +1 @@
import{b as f}from"./index-BQiEVoRj.js";function i(t){if(t==null)return[];if(typeof t=="string")return[t];if(Array.isArray(t))return t.flatMap(i);if(typeof t=="object"){const s=t;if("detail"in s){const e=i(s.detail);if(e.length>0)return e}const r=[];for(const[e,o]of Object.entries(s))if(e!=="detail")for(const n of i(o))r.push(e==="non_field_errors"?n:`${e}: ${n}`);return r}return[]}function g(t,s="Something went wrong"){var r,e;if(f.isAxiosError(t)){const o=(r=t.response)==null?void 0:r.data;if(o!==void 0){const n=i(o);if(n.length>0)return n.join(". ")}return(e=t.response)!=null&&e.status&&t.message.startsWith("Request failed")?s:t.message||s}return t instanceof Error?t.message:s}export{g};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{r as n}from"./index-BQiEVoRj.js";function m(t){const[c,s]=n.useState(!1);return n.useEffect(()=>{const e=window.matchMedia(t);s(e.matches);const a=r=>s(r.matches);return e.addEventListener("change",a),()=>e.removeEventListener("change",a)},[t]),c}const h={md:"(max-width: 768px)"};export{h as B,m as u};
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloud Reader</title>
<script type="module" crossorigin src="/assets/index-BQiEVoRj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bc1rr75j.css">
</head>
<body>
<div id="root"></div>
</body>
+2
View File
@@ -15,6 +15,8 @@
"dompurify": "^3.4.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18n-lite": "^1.0.10",
"react-reader": "^2.0.15",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
+22 -9
View File
@@ -1,12 +1,14 @@
import React, { lazy, Suspense, useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes, useParams } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./hooks/useToast";
import { AnnotationsProvider } from "./context/AnnotationsContext";
import { useTranslation } from "react-i18n-lite";
const LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: m.LibraryPage })));
const BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage })));
const ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage })));
const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage })));
const EditEBookPage = lazy(() => import("./pages/EditEBook").then((m) => ({ default: m.EditEBookPage })));
const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage })));
const BookmarksNotesPage = lazy(() => import("./components/annotations/BookmarksNotesPage").then((m) => ({ default: m.BookmarksNotesPage })));
const ReadingPage = lazy(() => import("./pages/ReadingPage").then((m) => ({ default: m.default })));
@@ -21,7 +23,8 @@ const AuthPage = lazy(() =>
);
function LoadingFallback() {
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", color: "#888", fontSize: 16 }}><p>Loading...</p></div>;
const { t } = useTranslation();
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", color: "#888", fontSize: 16 }}><p>{t("common.loading")}</p></div>;
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -31,6 +34,11 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function ReaderRedirect() {
const { id } = useParams<{ id: string }>();
return <Navigate to={`/read/${id ?? ""}`} replace />;
}
function AppRoutes() {
const { isAuthenticated } = useAuth();
return (
@@ -39,10 +47,9 @@ function AppRoutes() {
<Route path="/auth" element={isAuthenticated ? <Navigate to="/" replace /> : <AuthPage />} />
<Route path="/" element={<ProtectedRoute><LibraryPage /></ProtectedRoute>} />
<Route path="/books/:id" element={<ProtectedRoute><BookDetailPage /></ProtectedRoute>} />
<Route path="/reader/:id" element={<ProtectedRoute><ReaderPage /></ProtectedRoute>} />
<Route path="/read/:id" element={<ProtectedRoute><ReadingPage /></ProtectedRoute>} />
<Route path="/reader/:id" element={<ProtectedRoute><ReaderRedirect /></ProtectedRoute>} />
<Route path="/add" element={<ProtectedRoute><AddBookPage /></ProtectedRoute>} />
<Route path="/edit/:id" element={<ProtectedRoute><EditEBookPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/bookmarks-notes/:bookId?" element={<ProtectedRoute><BookmarksNotesPage /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -54,9 +61,15 @@ function AppRoutes() {
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
<I18nProvider>
<AuthProvider>
<ToastProvider>
<AnnotationsProvider>
<AppRoutes />
</AnnotationsProvider>
</ToastProvider>
</AuthProvider>
</I18nProvider>
</BrowserRouter>
);
}
+9 -73
View File
@@ -1,96 +1,32 @@
import api from "@/api/client";
import type {
Bookmark,
CreateBookmarkPayload,
Note,
CreateNotePayload,
UpdateNotePayload,
CreateMarkerPayload,
PaginatedResponse,
} from "@/types";
/** Fetch bookmarks for the current user, optionally filtered by book */
export async function fetchBookmarks(
bookId?: string
ebookId?: string | number,
): Promise<PaginatedResponse<Bookmark>> {
const params: Record<string, string> = {};
if (bookId) params.book = bookId;
if (ebookId != null && ebookId !== "") params.ebook = String(ebookId);
const { data } = await api.get<PaginatedResponse<Bookmark>>(
"/annotations/bookmarks/",
{ params }
{ params },
);
return data;
}
/** Create a new bookmark */
export async function createBookmark(
payload: CreateBookmarkPayload
export async function createMarker(
payload: CreateMarkerPayload,
): Promise<Bookmark> {
const { data } = await api.post<Bookmark>(
"/annotations/bookmarks/",
payload
);
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;
}
/** @deprecated Use createMarker */
export const createBookmark = createMarker;
+20 -24
View File
@@ -13,28 +13,9 @@ import type {
export const booksApi = {
async getEBooks(): Promise<EBookListItem[]> {
const { data } = await api.get<EBookListItem[]>("/books/ebooks/");
return data;
},
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params });
return data;
},
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
},
async getGenres(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/genres/");
return data;
},
async getAuthors(): Promise<string[]> {
const { data } = await api.get<string[]>("/books/authors/");
return data;
const { data } = await api.get<{ count: number; results: EBookListItem[] } | EBookListItem[]>("/books/ebooks/");
if (Array.isArray(data)) return data;
return data.results ?? [];
},
async getEBook(id: number): Promise<EBookDetail> {
@@ -42,11 +23,21 @@ export const booksApi = {
return data;
},
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] }>("/books/", { params });
async getEpubFile(id: number): Promise<Blob> {
const { data } = await api.get<Blob>(`/books/ebooks/${id}/file/`, {
responseType: "blob",
});
return data;
},
async searchBooks(params: BookSearchParams = {}): Promise<{ count: number; results: BookListItem[] }> {
const { data } = await api.get<{ count: number; results: BookListItem[] } | BookListItem[]>("/books/", { params });
if (Array.isArray(data)) {
return { count: data.length, results: data };
}
return { count: data.count ?? 0, results: data.results ?? [] };
},
async getBook(id: number): Promise<BookDetail> {
const { data } = await api.get<BookDetail>(`/books/${id}/`);
return data;
@@ -84,6 +75,11 @@ export const booksApi = {
await api.delete(`/books/ebooks/${id}/`);
},
async enrichEBookMetadata(id: number): Promise<EBookDetail> {
const { data } = await api.post<EBookDetail>(`/books/ebooks/${id}/enrich-metadata/`);
return data;
},
async processEBook(id: number): Promise<{ status: string }> {
const { data } = await api.post<{ status: string }>(`/books/ebooks/${id}/process/`);
return data;
+1 -1
View File
@@ -56,7 +56,7 @@ api.interceptors.response.use(
} catch {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
window.location.href = "/auth";
return Promise.reject(error);
} finally {
isRefreshing = false;
+40
View File
@@ -0,0 +1,40 @@
import axios from "axios";
function messagesFromValue(value: unknown): string[] {
if (value == null) return [];
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.flatMap(messagesFromValue);
if (typeof value === "object") {
const record = value as Record<string, unknown>;
if ("detail" in record) {
const fromDetail = messagesFromValue(record.detail);
if (fromDetail.length > 0) return fromDetail;
}
const messages: string[] = [];
for (const [key, nested] of Object.entries(record)) {
if (key === "detail") continue;
for (const part of messagesFromValue(nested)) {
messages.push(key === "non_field_errors" ? part : `${key}: ${part}`);
}
}
return messages;
}
return [];
}
/** Extract human-readable message(s) from a Django REST Framework / axios error response. */
export function getApiErrorMessage(err: unknown, fallback = "Something went wrong"): string {
if (axios.isAxiosError(err)) {
const data = err.response?.data;
if (data !== undefined) {
const messages = messagesFromValue(data);
if (messages.length > 0) return messages.join(". ");
}
if (err.response?.status && err.message.startsWith("Request failed")) {
return fallback;
}
return err.message || fallback;
}
if (err instanceof Error) return err.message;
return fallback;
}
+61 -94
View File
@@ -1,7 +1,9 @@
/**
* API client for the reader module — reading settings, chapters, and progress.
* Uses the shared axios client so JWT auth is attached automatically.
*/
import api from "./client";
import type {
ChapterDetail,
ChapterSummary,
@@ -9,140 +11,105 @@ import type {
ReadingSettings,
} from "../types/reader";
const API_BASE = "/api";
interface TocChapter {
id: number;
title: string;
index: number;
href?: string;
}
/**
* Fetch the current user's reading settings.
* Auto-creates defaults on the server if none exist.
*/
export async function getReadingSettings(): Promise<ReadingSettings> {
const response = await fetch(`${API_BASE}/reader/settings/`);
if (!response.ok) {
throw new Error(
`Failed to fetch reading settings: ${response.status} ${response.statusText}`
);
}
return response.json() as Promise<ReadingSettings>;
const { data } = await api.get<ReadingSettings>("/reader/settings/");
return data;
}
/**
* Update (full or partial) the user's reading settings.
*/
export async function updateReadingSettings(
settings: Partial<ReadingSettings>
settings: Partial<ReadingSettings>,
): Promise<ReadingSettings> {
const method = settings.theme !== undefined ? "PUT" : "PATCH";
const response = await fetch(`${API_BASE}/reader/settings/`, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error(
`Failed to update reading settings: ${response.status} ${response.statusText}`
);
}
return response.json() as Promise<ReadingSettings>;
const { data } = await api.patch<ReadingSettings>("/reader/settings/", settings);
return data;
}
/**
* Fetch the table of contents (chapter list) for a book (ebook).
* Maps to EBookViewSet.toc → GET /api/books/ebooks/{id}/toc/
*/
export async function getChapters(bookId: number): Promise<ChapterSummary[]> {
const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/toc/`);
if (!response.ok) {
throw new Error(
`Failed to fetch chapters: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
// Main backend wraps chapters under a "chapters" key
return (data.chapters ?? data) as ChapterSummary[];
const { data } = await api.get<{ chapters: TocChapter[] }>(
`/books/ebooks/${bookId}/toc/`,
);
const chapters = data.chapters ?? [];
return chapters.map((ch) => ({
id: ch.id,
book: bookId,
title: ch.title,
number: ch.index + 1,
}));
}
/**
* Fetch a specific chapter with full content for reading.
* Maps to EBookViewSet.content → GET /api/books/ebooks/{id}/content/?page={number}
*/
export async function getChapterContent(
bookId: number,
chapterNumber: number
chapterNumber: number,
): Promise<ChapterDetail> {
const response = await fetch(
`${API_BASE}/books/ebooks/${bookId}/content/?page=${chapterNumber}`
);
if (!response.ok) {
throw new Error(
`Failed to fetch chapter ${chapterNumber}: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
// Main backend returns: { page, total_pages, content, chapter_title, format }
const { data } = await api.get<{
page: number;
chapter_title: string;
content: string;
}>(`/books/ebooks/${bookId}/content/`, { params: { page: chapterNumber } });
return {
id: chapterNumber,
book: bookId,
title: data.chapter_title ?? "",
number: data.page,
number: data.page ?? chapterNumber,
content: data.content ?? "",
created_at: "",
updated_at: "",
} as ChapterDetail;
};
}
/**
* Fetch reading progress for a book (ebook).
* Maps to EBookViewSet.progress → GET /api/books/ebooks/{id}/progress/
*/
export async function getReadingProgress(
bookId: number
bookId: number,
): Promise<ReadingProgress> {
const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/progress/`);
if (!response.ok) {
throw new Error(
`Failed to fetch reading progress: ${response.status} ${response.statusText}`
);
}
const data = await response.json();
// Main backend returns: { current_position, last_page, version, updated_at }
const { data } = await api.get<{
current_position: number;
last_page: number;
epub_location?: string;
updated_at?: string;
}>(`/books/ebooks/${bookId}/progress/`);
return {
id: bookId,
book: bookId,
current_chapter: Math.floor((data.current_position ?? 0) / 10) + 1,
current_chapter: data.last_page || 1,
current_position: data.current_position ?? 0,
percentage: data.current_position ?? 0,
epub_location: data.epub_location ?? "",
updated_at: data.updated_at ?? "",
} as ReadingProgress;
};
}
/**
* Update reading progress for a book (ebook).
* Maps to EBookViewSet.progress → PATCH /api/books/ebooks/{id}/progress/
*/
export async function updateReadingProgress(
bookId: number,
progress: Partial<ReadingProgress>
progress: Partial<ReadingProgress>,
): Promise<ReadingProgress> {
const response = await fetch(`${API_BASE}/books/ebooks/${bookId}/progress/`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_position: progress.percentage ?? progress.current_position ?? 0,
last_page: progress.current_chapter ?? 0,
}),
});
if (!response.ok) {
throw new Error(
`Failed to update reading progress: ${response.status} ${response.statusText}`
);
const body: Record<string, string | number> = {};
if (progress.percentage !== undefined || progress.current_position !== undefined) {
body.current_position = progress.percentage ?? progress.current_position ?? 0;
}
const data = await response.json();
if (progress.current_chapter !== undefined) {
body.last_page = progress.current_chapter;
}
if (progress.epub_location !== undefined) {
body.epub_location = progress.epub_location;
}
const { data } = await api.patch<{
current_position: number;
last_page: number;
epub_location?: string;
updated_at?: string;
}>(`/books/ebooks/${bookId}/progress/`, body);
return {
id: bookId,
book: bookId,
current_chapter: Math.floor((data.current_position ?? 0) / 10) + 1,
current_chapter: data.last_page || 1,
current_position: data.current_position ?? 0,
percentage: data.current_position ?? 0,
epub_location: data.epub_location ?? "",
updated_at: data.updated_at ?? "",
} as ReadingProgress;
}
};
}
@@ -0,0 +1,51 @@
.menu {
position: fixed;
z-index: 9999;
min-width: 180px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
padding: 4px 0;
overflow: hidden;
}
.item {
display: block;
width: 100%;
padding: 10px 16px;
border: none;
background: transparent;
text-align: left;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: background 0.1s;
}
.item:hover:not(:disabled) {
background: #f3f4f6;
}
.item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.itemDanger {
color: #dc2626;
}
.itemDanger:hover:not(:disabled) {
background: #fef2f2;
}
.itemLoading {
color: #6b7280;
}
.separator {
height: 1px;
background: #e5e7eb;
margin: 4px 0;
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books";
import { getApiErrorMessage } from "../api/errors";
import { useToast } from "../hooks/useToast";
import type { BookListItem } from "../types/book";
import styles from "./BookContextMenu.module.css";
type LibraryBook = BookListItem & { format: string; progressPercent: number | null };
interface BookContextMenuProps {
book: LibraryBook;
x: number;
y: number;
onClose: () => void;
onBookUpdated: (book: LibraryBook) => void;
onBookRemoved: (id: number) => void;
}
type ActionKind = "sync" | "remove" | null;
function clampPosition(x: number, y: number, width: number, height: number) {
const padding = 8;
const maxX = window.innerWidth - width - padding;
const maxY = window.innerHeight - height - padding;
return {
left: Math.max(padding, Math.min(x, maxX)),
top: Math.max(padding, Math.min(y, maxY)),
};
}
export function BookContextMenu({
book,
x,
y,
onClose,
onBookUpdated,
onBookRemoved,
}: BookContextMenuProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ left: x, top: y });
const [activeAction, setActiveAction] = useState<ActionKind>(null);
useLayoutEffect(() => {
const el = menuRef.current;
if (!el) return;
setPosition(clampPosition(x, y, el.offsetWidth, el.offsetHeight));
}, [x, y]);
useEffect(() => {
const handlePointerDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const handleScroll = () => onClose();
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
const handleSyncMetadata = async () => {
setActiveAction("sync");
try {
const updated = await booksApi.enrichEBookMetadata(book.id);
onBookUpdated({
...book,
id: updated.id,
title: updated.title,
author: updated.author,
genre: updated.format ? updated.format.toUpperCase() : "",
format: updated.format,
cover_image: updated.cover_image,
progressPercent: book.progressPercent,
});
const status = updated.metadata?.match_status;
if (status === "matched") {
showToast({ message: t("toast.metadataSynced", { title: updated.title }), variant: "success" });
} else if (status === "not_found") {
showToast({ message: t("toast.noMatch"), variant: "warning" });
} else {
showToast({ message: t("toast.refreshDone"), variant: "success" });
}
} catch (err) {
showToast({ message: getApiErrorMessage(err, t("common.unknownError")), variant: "error" });
} finally {
setActiveAction(null);
onClose();
}
};
const handleRemove = async () => {
const confirmed = window.confirm(t("contextMenu.confirmRemove", { title: book.title }));
if (!confirmed) return;
setActiveAction("remove");
try {
await booksApi.deleteEBook(book.id);
onBookRemoved(book.id);
showToast({ message: t("toast.removed", { title: book.title }), variant: "success" });
} catch (err) {
showToast({ message: getApiErrorMessage(err, t("common.unknownError")), variant: "error" });
} finally {
setActiveAction(null);
onClose();
}
};
const busy = activeAction !== null;
return (
<div
ref={menuRef}
className={styles.menu}
style={{ left: position.left, top: position.top }}
role="menu"
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button
type="button"
className={`${styles.item} ${activeAction === "sync" ? styles.itemLoading : ""}`}
role="menuitem"
disabled={busy}
onClick={() => void handleSyncMetadata()}
>
{activeAction === "sync" ? t("contextMenu.syncingMetadata") : t("contextMenu.syncMetadata")}
</button>
<div className={styles.separator} role="separator" />
<button
type="button"
className={`${styles.item} ${styles.itemDanger} ${activeAction === "remove" ? styles.itemLoading : ""}`}
role="menuitem"
disabled={busy}
onClick={() => void handleRemove()}
>
{activeAction === "remove" ? t("contextMenu.removing") : t("contextMenu.remove")}
</button>
</div>
);
}
@@ -0,0 +1,50 @@
.container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
max-width: min(360px, calc(100vw - 32px));
}
.toast {
pointer-events: auto;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
animation: slideIn 0.2s ease-out;
}
.success {
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.warning {
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -0,0 +1,20 @@
import type { ToastItem } from "../hooks/useToast";
import styles from "./ToastContainer.module.css";
interface ToastContainerProps {
toasts: ToastItem[];
}
export function ToastContainer({ toasts }: ToastContainerProps) {
if (toasts.length === 0) return null;
return (
<div className={styles.container} role="status" aria-live="polite">
{toasts.map((toast) => (
<div key={toast.id} className={`${styles.toast} ${styles[toast.variant]}`}>
{toast.message}
</div>
))}
</div>
);
}
@@ -1,120 +0,0 @@
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">
&times;
</button>
<h3>Add to Page {page}</h3>
{locationText && (
<blockquote className="annotation-quote">
&ldquo;{locationText}&rdquo;
</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>
);
}
@@ -1,109 +0,0 @@
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">&middot;</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">
&ldquo;{entry.location_text}&rdquo;
</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>
);
}
@@ -1,87 +0,0 @@
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">
&ldquo;{bookmark.location_text}&rdquo;
</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>
);
}
@@ -1,23 +1,38 @@
import React from "react";
import { AnnotationsDashboard } from "@/components/annotations";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { MarkerThreadsView } from "./MarkerThreadsView";
interface BookmarksNotesPageProps {
bookId?: string;
}
export function BookmarksNotesPage(): React.ReactElement {
const { t } = useTranslation();
const navigate = useNavigate();
const { bookId } = useParams<{ 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 className="page bookmarks-notes-page" style={{ maxWidth: 720, margin: "0 auto", padding: 16 }}>
<header style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24 }}>
<button
type="button"
onClick={() => navigate("/")}
style={{
padding: "8px 16px",
borderRadius: 6,
border: "1px solid #ddd",
background: "#fff",
cursor: "pointer",
fontSize: 14,
}}
>
{t("common.back")}
</button>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>{t("annotations.title")}</h2>
</header>
<MarkerThreadsView
ebookIdFilter={bookId}
onGoToPassage={(ebookId, epubCfi) => {
navigate(`/read/${ebookId}`, { state: { epubLocation: epubCfi } });
}}
/>
</div>
);
}
}
@@ -0,0 +1,90 @@
import { useState } from "react";
import { useTranslation } from "react-i18n-lite";
import {
highlightBackgroundStyle,
markerPassageStyle,
normalizeHighlightColor,
} from "@/constants/bookmarkHighlightColors";
const PASSAGE_COLLAPSE_CHARS = 200;
const THOUGHT_COLLAPSE_CHARS = 120;
interface CollapsibleMarkerPassageProps {
text: string;
highlightColor?: string | null;
className?: string;
}
export function CollapsibleMarkerPassage({
text,
highlightColor,
className = "annotation-quote marker-thread-passage",
}: CollapsibleMarkerPassageProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const collapsible = text.length > PASSAGE_COLLAPSE_CHARS;
const color = normalizeHighlightColor(highlightColor);
return (
<blockquote className={className} style={markerPassageStyle(color)}>
<span
className={
collapsible && !expanded
? "marker-passage-text marker-passage-text--clamped"
: "marker-passage-text"
}
style={highlightBackgroundStyle(color)}
>
&ldquo;{text}&rdquo;
</span>
{collapsible && (
<button
type="button"
className="marker-text-toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
{expanded ? t("annotations.showLess") : t("annotations.showMore")}
</button>
)}
</blockquote>
);
}
interface CollapsibleMarkerThoughtProps {
text: string;
className?: string;
}
export function CollapsibleMarkerThought({
text,
className = "marker-thread-thought",
}: CollapsibleMarkerThoughtProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const collapsible = text.length > THOUGHT_COLLAPSE_CHARS;
return (
<div className="marker-thought-wrap">
<p
className={
collapsible && !expanded
? `${className} marker-thought-text marker-thought-text--clamped`
: `${className} marker-thought-text`
}
>
{text}
</p>
{collapsible && (
<button
type="button"
className="marker-text-toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
{expanded ? t("annotations.showLess") : t("annotations.showMore")}
</button>
)}
</div>
);
}
@@ -0,0 +1,119 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { MarkerEntry } from "@/types";
import {
CollapsibleMarkerPassage,
CollapsibleMarkerThought,
} from "@/components/annotations/CollapsibleMarkerText";
interface MarkerThreadsViewProps {
ebookIdFilter?: string;
onGoToPassage: (ebookId: number, epubCfi: string) => void;
}
export function MarkerThreadsView({
ebookIdFilter,
onGoToPassage,
}: MarkerThreadsViewProps) {
const { t } = useTranslation();
const { markersByBook, loadBookmarks, removeBookmark, state } = useAnnotations();
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
useEffect(() => {
void loadBookmarks(ebookIdFilter);
}, [loadBookmarks, ebookIdFilter]);
const groups = useMemo(() => {
if (!ebookIdFilter) return markersByBook;
const id = Number(ebookIdFilter);
return markersByBook.filter((g) => g.ebookId === id);
}, [markersByBook, ebookIdFilter]);
if (state.bookmarksLoading) {
return <div className="annotations-loading">{t("annotations.loading")}</div>;
}
if (groups.length === 0) {
return (
<div className="annotations-empty">
<p>{t("annotations.empty")}</p>
<p style={{ fontSize: 14, color: "#6b7280", marginTop: 8 }}>{t("annotations.selectTextHint")}</p>
</div>
);
}
const toggleBook = (ebookId: number) => {
setCollapsed((prev) => ({ ...prev, [ebookId]: !prev[ebookId] }));
};
return (
<div className="marker-books-list">
{groups.map((group) => {
const isCollapsed = collapsed[group.ebookId] ?? false;
return (
<section key={group.ebookId} className="marker-book-group">
<button
type="button"
className="marker-book-header"
onClick={() => toggleBook(group.ebookId)}
>
<span className="marker-book-title">{group.ebookTitle}</span>
<span className="marker-book-count">
{t("annotations.markerCount", { count: String(group.markers.length) })}
</span>
<span className="marker-book-chevron">{isCollapsed ? "▸" : "▾"}</span>
</button>
{!isCollapsed && (
<ul className="marker-thread-list">
{group.markers.map((m: MarkerEntry) => (
<li key={m.id} className="marker-thread">
<div className="marker-thread-meta">
{m.chapter_title ? (
<span className="marker-thread-chapter">{m.chapter_title}</span>
) : (
<span className="marker-thread-chapter">
{t("annotations.chapterIndex", { index: String(m.chapter_index + 1) })}
</span>
)}
</div>
{m.location_text && (
<CollapsibleMarkerPassage
text={m.location_text}
highlightColor={m.highlight_color}
/>
)}
{m.content ? (
<CollapsibleMarkerThought
text={m.content}
className="marker-thread-thought marker-thread-reply"
/>
) : (
<span className="annotation-kind-badge bookmark-badge">{t("annotations.bookmarkOnly")}</span>
)}
<div className="annotation-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onGoToPassage(m.ebook_id, m.epub_cfi)}
>
{t("annotations.goToPassage")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => removeBookmark(m.id)}
>
{t("common.delete")}
</button>
</div>
</li>
))}
</ul>
)}
</section>
);
})}
</div>
);
}
@@ -1,148 +0,0 @@
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">
&ldquo;{note.location_text}&rdquo;
</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>
);
}
+2 -4
View File
@@ -1,4 +1,2 @@
export { BookmarkList } from "./BookmarkList";
export { NoteList } from "./NoteList";
export { AddAnnotationForm } from "./AddAnnotationForm";
export { AnnotationsDashboard } from "./AnnotationsDashboard";
export { BookmarksNotesPage } from "./BookmarksNotesPage";
export { MarkerThreadsView } from "./MarkerThreadsView";
+16 -15
View File
@@ -1,5 +1,5 @@
import React from "react";
import { AnnotationsProvider } from "@/context/AnnotationsContext";
import { useTranslation } from "react-i18n-lite";
interface LayoutProps {
children: React.ReactNode;
@@ -8,20 +8,21 @@ interface LayoutProps {
export function Layout({
children,
title = "Cloud Reader",
title,
}: LayoutProps): React.ReactElement {
const { t } = useTranslation();
const pageTitle = title ?? t("common.appName");
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>
<div className="app-container">
<header className="app-header">
<h1 className="app-title">{pageTitle}</h1>
<nav className="app-nav">
<a href="/" className="nav-link">{t("annotations.home")}</a>
<a href="/bookmarks-notes" className="nav-link">{t("annotations.bookmarksNotes")}</a>
</nav>
</header>
<main className="app-main">{children}</main>
</div>
);
}
}
@@ -0,0 +1,140 @@
.shelf {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 90;
background: #fff;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08);
transition: max-height 0.25s ease;
}
.shelfCollapsed {
max-height: 52px;
}
.shelfExpanded {
max-height: min(42vh, 320px);
}
.shelfHandle {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: none;
background: #fafafa;
cursor: pointer;
font: inherit;
text-align: left;
}
.shelfHandle:hover {
background: #f3f4f6;
}
.shelfHandleTitle {
font-weight: 700;
font-size: 14px;
color: #1f2937;
}
.shelfHandleCount {
font-size: 13px;
color: #6b7280;
flex: 1;
}
.shelfChevron {
font-size: 14px;
color: #9333ea;
font-weight: 700;
}
.shelfBody {
overflow-y: auto;
max-height: calc(min(42vh, 320px) - 52px);
padding: 12px 16px 16px;
border-top: 1px solid #f3f4f6;
}
.shelfGrid {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
}
.shelfCard {
flex: 0 0 120px;
scroll-snap-align: start;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
background: #fff;
border: 1px solid #e5e7eb;
transition: transform 0.15s, box-shadow 0.15s;
}
.shelfCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.shelfCover {
position: relative;
height: 160px;
background: #f0f0f0;
}
.shelfCover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.shelfCoverPlaceholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 32px;
}
.shelfBadge {
position: absolute;
top: 6px;
right: 6px;
background: #f3e8ff;
color: #9333ea;
font-size: 9px;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
}
.shelfMeta {
padding: 8px;
}
.shelfTitle {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shelfAuthor {
margin: 2px 0 0;
font-size: 11px;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18n-lite";
import styles from "./FinishedBooksShelf.module.css";
export interface ShelfBook {
id: number;
title: string;
author: string;
cover_image: string | null;
format: string;
genre: string;
}
interface FinishedBooksShelfProps {
books: ShelfBook[];
defaultExpanded?: boolean;
onOpenBook: (book: ShelfBook) => void;
onContextMenu: (e: React.MouseEvent, book: ShelfBook) => void;
}
export function FinishedBooksShelf({
books,
defaultExpanded = false,
onOpenBook,
onContextMenu,
}: FinishedBooksShelfProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => {
if (defaultExpanded) setExpanded(true);
}, [defaultExpanded]);
if (books.length === 0) return null;
return (
<div
className={`${styles.shelf} ${expanded ? styles.shelfExpanded : styles.shelfCollapsed}`}
aria-label={t("library.finishedSection")}
>
<button
type="button"
className={styles.shelfHandle}
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<span className={styles.shelfHandleTitle}>{t("library.finishedSection")}</span>
<span className={styles.shelfHandleCount}>
{t("library.finishedCount", { count: String(books.length) })}
</span>
<span className={styles.shelfChevron} aria-hidden>
{expanded ? "▾" : "▴"}
</span>
</button>
{expanded && (
<div className={styles.shelfBody}>
<div className={styles.shelfGrid}>
{books.map((book) => (
<div
key={book.id}
role="button"
tabIndex={0}
className={styles.shelfCard}
onClick={() => onOpenBook(book)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenBook(book);
}
}}
onContextMenu={(e) => onContextMenu(e, book)}
>
<div className={styles.shelfCover}>
{book.cover_image ? (
<img src={book.cover_image} alt="" />
) : (
<span className={styles.shelfCoverPlaceholder}>📖</span>
)}
<span className={styles.shelfBadge}>{t("library.readingStatus.finished")}</span>
</div>
<div className={styles.shelfMeta}>
<p className={styles.shelfTitle}>{book.title}</p>
<p className={styles.shelfAuthor}>{book.author}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,176 @@
import { useTranslation } from "react-i18n-lite";
import { readingStatusKey } from "@/locales";
import styles from "../../pages/Library.module.css";
export const LIBRARY_STATUS_COLORS: Record<string, { bg: string; text: string }> = {
want_to_read: { bg: "#dbeafe", text: "#1d4ed8" },
reading: { bg: "#dcfce7", text: "#16a34a" },
finished: { bg: "#f3e8ff", text: "#9333ea" },
dnf: { bg: "#fef3c7", text: "#b45309" },
};
export interface LibraryBookCardData {
id: number;
title: string;
author: string;
genre: string;
format: string;
cover_image: string | null;
reading_status: string;
progressPercent: number | null;
}
function badgeLabel(
book: LibraryBookCardData,
t: (key: string, params?: Record<string, string>) => string,
): string {
if (book.reading_status === "reading" && book.progressPercent != null && book.progressPercent > 0) {
return t("library.readingStatus.readingWithProgress", {
percent: String(book.progressPercent),
});
}
return t(readingStatusKey(book.reading_status));
}
interface LibraryBookCardProps {
book: LibraryBookCardData;
isMobile: boolean;
onOpen: () => void;
onContextMenu: (e: React.MouseEvent) => void;
}
export function LibraryBookCard({
book,
isMobile,
onOpen,
onContextMenu,
}: LibraryBookCardProps) {
const { t } = useTranslation();
const sc = LIBRARY_STATUS_COLORS[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" };
const showProgressBar =
book.reading_status === "reading" &&
book.progressPercent != null &&
book.progressPercent > 0;
return (
<div
onClick={onOpen}
onContextMenu={onContextMenu}
className={styles.bookCard}
style={{ display: isMobile ? "flex" : "block" }}
>
<div
style={{
width: isMobile ? 80 : "100%",
height: isMobile ? 120 : 180,
background: "#f0f0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}
>
{book.cover_image ? (
<img
src={book.cover_image}
alt={book.title}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<span style={{ fontSize: isMobile ? 32 : 48 }}>📖</span>
)}
{showProgressBar && (
<div className={styles.coverProgressBar}>
<div
className={styles.coverProgressFill}
style={{ width: `${book.progressPercent}%` }}
/>
</div>
)}
{!isMobile && (
<span
style={{
position: "absolute",
top: 8,
right: 8,
background: sc.bg,
color: sc.text,
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
borderRadius: 999,
lineHeight: "18px",
maxWidth: "calc(100% - 16px)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{badgeLabel(book, t)}
</span>
)}
</div>
<div style={{ padding: isMobile ? "8px 12px" : 12, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<h3
style={{
fontSize: 14,
fontWeight: 600,
color: "#1f2937",
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{book.title}
</h3>
<p style={{ fontSize: 12, color: "#6b7280", margin: "2px 0" }}>
{book.author || t("common.unknownAuthor")}
</p>
</div>
{isMobile && (
<span
style={{
background: sc.bg,
color: sc.text,
fontSize: 10,
fontWeight: 600,
padding: "2px 6px",
borderRadius: 999,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{badgeLabel(book, t)}
</span>
)}
</div>
{book.genre && (
<span
style={{
fontSize: 11,
color: "#4f46e5",
background: "#eef2ff",
padding: "1px 6px",
borderRadius: 4,
display: "inline-block",
marginTop: 4,
}}
>
{book.genre}
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18n-lite";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { MarkerEntry } from "@/types";
import {
CollapsibleMarkerPassage,
CollapsibleMarkerThought,
} from "@/components/annotations/CollapsibleMarkerText";
interface BookMarkersPanelProps {
ebookId: number;
isOpen: boolean;
onClose: () => void;
onGoToPassage: (marker: MarkerEntry) => void;
}
export function BookMarkersPanel({
ebookId,
isOpen,
onClose,
onGoToPassage,
}: BookMarkersPanelProps) {
const { t } = useTranslation();
const { markers, loadBookmarks, removeBookmark, state } = useAnnotations();
useEffect(() => {
if (isOpen) void loadBookmarks(ebookId);
}, [isOpen, ebookId, loadBookmarks]);
if (!isOpen) return null;
return (
<>
<div className="reader-panel-backdrop" onClick={onClose} aria-hidden />
<aside className="reader-side-panel reader-markers-panel" role="dialog" aria-label={t("annotations.inBookPanel")}>
<header className="reader-panel-header">
<h2>{t("annotations.inBookPanel")}</h2>
<button type="button" className="reader-panel-close" onClick={onClose} aria-label={t("annotations.close")}>
×
</button>
</header>
<div className="reader-panel-body">
{state.bookmarksLoading ? (
<p className="annotations-loading">{t("annotations.loading")}</p>
) : markers.length === 0 ? (
<p className="annotations-empty">{t("annotations.selectTextHint")}</p>
) : (
<ul className="marker-thread-list">
{markers.map((m) => (
<li key={m.id} className="marker-thread">
{m.chapter_title && (
<span className="marker-thread-chapter">{m.chapter_title}</span>
)}
{m.location_text && (
<CollapsibleMarkerPassage
text={m.location_text}
highlightColor={m.highlight_color}
className="annotation-quote"
/>
)}
{m.content ? (
<CollapsibleMarkerThought text={m.content} />
) : (
<span className="annotation-kind-badge bookmark-badge">{t("annotations.bookmarkOnly")}</span>
)}
<div className="annotation-actions">
<button type="button" className="btn btn-sm" onClick={() => onGoToPassage(m)}>
{t("annotations.goToPassage")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => removeBookmark(m.id)}
>
{t("common.delete")}
</button>
</div>
</li>
))}
</ul>
)}
</div>
</aside>
</>
);
}
@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18n-lite";
import {
BOOKMARK_HIGHLIGHT_PRESETS,
normalizeHighlightColor,
} from "@/constants/bookmarkHighlightColors";
interface BookmarkColorPickerProps {
value: string;
onChange: (color: string) => void;
}
export function BookmarkColorPicker({ value, onChange }: BookmarkColorPickerProps) {
const { t } = useTranslation();
const normalized = normalizeHighlightColor(value);
const isPreset = BOOKMARK_HIGHLIGHT_PRESETS.some((p) => p.value === normalized);
return (
<div className="bookmark-color-picker" role="group" aria-label={t("annotations.highlightColor")}>
<span className="bookmark-color-picker-label">{t("annotations.highlightColor")}</span>
<div className="bookmark-color-picker-swatches">
{BOOKMARK_HIGHLIGHT_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
className={`bookmark-color-swatch${normalized === preset.value ? " bookmark-color-swatch--active" : ""}`}
style={{ backgroundColor: preset.value }}
title={t(preset.labelKey)}
aria-label={t(preset.labelKey)}
aria-pressed={normalized === preset.value}
onClick={() => onChange(preset.value)}
/>
))}
<label className="bookmark-color-custom" title={t("annotations.highlightCustom")}>
<input
type="color"
className="bookmark-color-custom-input"
value={normalized}
onChange={(e) => onChange(e.target.value)}
aria-label={t("annotations.highlightCustom")}
/>
<span
className={`bookmark-color-swatch bookmark-color-swatch--custom${!isPreset ? " bookmark-color-swatch--active" : ""}`}
style={{ backgroundColor: normalized }}
aria-hidden
/>
</label>
</div>
</div>
);
}
@@ -1,61 +1,105 @@
/**
* ReaderToolbar — fixed bottom toolbar for the reading view.
* Provides TOC toggle, settings toggle, and progress indicator.
*/
import { useTranslation } from "react-i18n-lite";
import type { ReadingProgress } from "../../types/reader";
interface ReaderToolbarProps {
bookTitle: string;
chapterTitle: string;
progress: ReadingProgress | null;
onBack?: () => void;
onToggleToc: () => void;
onToggleSettings: () => void;
onToggleMarkers?: () => void;
}
export default function ReaderToolbar({
bookTitle,
chapterTitle,
progress,
onBack,
onToggleToc,
onToggleSettings,
onToggleMarkers,
}: ReaderToolbarProps) {
const { t } = useTranslation();
const percentage = progress?.percentage ?? 0;
return (
<>
{/* Top bar */}
<header className="reader-top-bar">
<button
type="button"
className="reader-bar-btn"
onClick={onToggleToc}
aria-label="Table of contents"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{onBack ? (
<button
type="button"
className="reader-bar-btn"
onClick={onBack}
aria-label={t("reader.backToLibraryAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12H4M10 18l-6-6 6-6" />
</svg>
</button>
) : (
<button
type="button"
className="reader-bar-btn"
onClick={onToggleToc}
aria-label={t("reader.tocAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<div className="reader-bar-title">
<span className="reader-bar-book">{bookTitle}</span>
<span className="reader-bar-chapter">{chapterTitle}</span>
</div>
<div className="reader-bar-actions">
{onBack && (
<button
type="button"
className="reader-bar-btn"
onClick={onToggleToc}
aria-label={t("reader.tocAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
{onToggleMarkers && (
<button
type="button"
className="reader-bar-btn"
onClick={onToggleMarkers}
aria-label={t("annotations.inBookPanel")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
)}
<button
type="button"
className="reader-bar-btn"
onClick={onToggleSettings}
aria-label="Reading settings"
aria-label={t("reader.settingsAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
</div>
</header>
{/* Bottom progress bar */}
<div className="reader-progress-bar">
<div
className="reader-progress-fill"
@@ -64,4 +108,4 @@ export default function ReaderToolbar({
</div>
</>
);
}
}
@@ -3,38 +3,44 @@
* the reading experience: theme, font, sizing, orientation.
*/
import { useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18n-lite";
import type {
FontFamily,
OrientationLock,
ReadingSettings,
ThemePreset,
} from "../../types/reader";
import type { SettingsPersistMode } from "../../hooks/useReadingSettings";
interface ReadingSettingsPanelProps {
settings: ReadingSettings;
isOpen: boolean;
onClose: () => void;
onUpdate: (partial: Partial<ReadingSettings>) => Promise<void>;
onUpdate: (
partial: Partial<ReadingSettings>,
persist?: SettingsPersistMode,
) => void;
onFlush: () => Promise<void>;
}
const THEME_OPTIONS: { value: ThemePreset; label: string }[] = [
{ value: "sepia", label: "Sepia" },
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" },
{ value: "paper", label: "Paper" },
const THEME_OPTIONS: { value: ThemePreset; labelKey: string }[] = [
{ value: "sepia", labelKey: "reader.themeSepia" },
{ value: "dark", labelKey: "reader.themeDark" },
{ value: "light", labelKey: "reader.themeLight" },
{ value: "paper", labelKey: "reader.themePaper" },
];
const FONT_OPTIONS: { value: FontFamily; label: string }[] = [
{ value: "sans-serif", label: "Sans-serif" },
{ value: "serif", label: "Serif" },
{ value: "monospace", label: "Monospace" },
const FONT_OPTIONS: { value: FontFamily; labelKey: string }[] = [
{ value: "sans-serif", labelKey: "reader.fontSans" },
{ value: "serif", labelKey: "reader.fontSerif" },
{ value: "monospace", labelKey: "reader.fontMonospace" },
];
const ORIENTATION_OPTIONS: { value: OrientationLock; label: string }[] = [
{ value: "auto", label: "Auto" },
{ value: "portrait", label: "Portrait" },
{ value: "landscape", label: "Landscape" },
const ORIENTATION_OPTIONS: { value: OrientationLock; labelKey: string }[] = [
{ value: "auto", labelKey: "reader.orientationAuto" },
{ value: "portrait", labelKey: "reader.orientationPortrait" },
{ value: "landscape", labelKey: "reader.orientationLandscape" },
];
export default function ReadingSettingsPanel({
@@ -42,46 +48,46 @@ export default function ReadingSettingsPanel({
isOpen,
onClose,
onUpdate,
onFlush,
}: ReadingSettingsPanelProps) {
const [saving, setSaving] = useState<Record<string, boolean>>({});
const { t } = useTranslation();
const handleChange = async (
key: keyof ReadingSettings,
value: string | number
) => {
setSaving((prev) => ({ ...prev, [key]: true }));
try {
await onUpdate({ [key]: value as never });
} finally {
setSaving((prev) => ({ ...prev, [key]: false }));
}
const handleClose = () => {
void onFlush();
onClose();
};
const handleSlider = (key: keyof ReadingSettings, value: number) => {
onUpdate({ [key]: value } as Partial<ReadingSettings>, "debounced");
};
const handleDiscrete = (key: keyof ReadingSettings, value: string | number) => {
onUpdate({ [key]: value } as Partial<ReadingSettings>, "immediate");
};
return (
<>
{/* Overlay */}
{isOpen && (
<div
className="settings-overlay"
onClick={onClose}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Escape") onClose();
onClick={handleClose}
onKeyDown={(e: ReactKeyboardEvent) => {
if (e.key === "Escape") handleClose();
}}
role="presentation"
/>
)}
{/* Drawer */}
<aside
className={`settings-drawer ${isOpen ? "settings-drawer--open" : ""}`}
>
<div className="settings-header">
<h2 className="settings-title">Reading Settings</h2>
<h2 className="settings-title">{t("reader.settingsTitle")}</h2>
<button
type="button"
className="settings-close-btn"
onClick={onClose}
aria-label="Close settings"
onClick={handleClose}
aria-label={t("reader.closeSettingsAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
@@ -91,9 +97,8 @@ export default function ReadingSettingsPanel({
</div>
<div className="settings-body">
{/* Theme Presets */}
<section className="settings-section">
<h3 className="settings-section-title">Theme</h3>
<h3 className="settings-section-title">{t("reader.theme")}</h3>
<div className="theme-grid">
{THEME_OPTIONS.map((opt) => (
<button
@@ -102,18 +107,16 @@ export default function ReadingSettingsPanel({
className={`theme-btn ${
settings.theme === opt.value ? "theme-btn--active" : ""
}`}
onClick={() => handleChange("theme", opt.value)}
disabled={saving.theme}
onClick={() => handleDiscrete("theme", opt.value)}
>
{opt.label}
{t(opt.labelKey)}
</button>
))}
</div>
</section>
{/* Font Family */}
<section className="settings-section">
<h3 className="settings-section-title">Font</h3>
<h3 className="settings-section-title">{t("reader.font")}</h3>
<div className="font-grid">
{FONT_OPTIONS.map((opt) => (
<button
@@ -122,19 +125,17 @@ export default function ReadingSettingsPanel({
className={`font-btn ${
settings.font_family === opt.value ? "font-btn--active" : ""
}`}
onClick={() => handleChange("font_family", opt.value)}
disabled={saving.font_family}
onClick={() => handleDiscrete("font_family", opt.value)}
>
{opt.label}
{t(opt.labelKey)}
</button>
))}
</div>
</section>
{/* Font Size Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Font Size: {settings.font_size}px
{t("reader.fontSize", { size: String(settings.font_size) })}
</h3>
<input
type="range"
@@ -142,17 +143,16 @@ export default function ReadingSettingsPanel({
max="32"
value={settings.font_size}
onChange={(e) =>
handleChange("font_size", Number(e.target.value))
handleSlider("font_size", Number(e.target.value))
}
className="settings-slider"
aria-label="Font size"
aria-label={t("reader.fontSizeAria")}
/>
</section>
{/* Line Height Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Line Height: {settings.line_height.toFixed(1)}
{t("reader.lineHeight", { value: settings.line_height.toFixed(1) })}
</h3>
<input
type="range"
@@ -161,17 +161,16 @@ export default function ReadingSettingsPanel({
step="0.1"
value={settings.line_height}
onChange={(e) =>
handleChange("line_height", Number(e.target.value))
handleSlider("line_height", Number(e.target.value))
}
className="settings-slider"
aria-label="Line height"
aria-label={t("reader.lineHeightAria")}
/>
</section>
{/* Margin Width Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Margins: {settings.margin_width}px
{t("reader.margins", { value: String(settings.margin_width) })}
</h3>
<input
type="range"
@@ -179,17 +178,16 @@ export default function ReadingSettingsPanel({
max="48"
value={settings.margin_width}
onChange={(e) =>
handleChange("margin_width", Number(e.target.value))
handleSlider("margin_width", Number(e.target.value))
}
className="settings-slider"
aria-label="Margin width"
aria-label={t("reader.marginWidthAria")}
/>
</section>
{/* Brightness Slider */}
<section className="settings-section">
<h3 className="settings-section-title">
Brightness: {settings.brightness}%
{t("reader.brightness", { value: String(settings.brightness) })}
</h3>
<input
type="range"
@@ -197,16 +195,15 @@ export default function ReadingSettingsPanel({
max="100"
value={settings.brightness}
onChange={(e) =>
handleChange("brightness", Number(e.target.value))
handleSlider("brightness", Number(e.target.value))
}
className="settings-slider"
aria-label="Brightness"
aria-label={t("reader.brightnessAria")}
/>
</section>
{/* Orientation Lock */}
<section className="settings-section">
<h3 className="settings-section-title">Orientation</h3>
<h3 className="settings-section-title">{t("reader.orientation")}</h3>
<div className="orientation-grid">
{ORIENTATION_OPTIONS.map((opt) => (
<button
@@ -217,10 +214,9 @@ export default function ReadingSettingsPanel({
? "orientation-btn--active"
: ""
}`}
onClick={() => handleChange("orientation_lock", opt.value)}
disabled={saving.orientation_lock}
onClick={() => handleDiscrete("orientation_lock", opt.value)}
>
{opt.label}
{t(opt.labelKey)}
</button>
))}
</div>
@@ -229,4 +225,4 @@ export default function ReadingSettingsPanel({
</aside>
</>
);
}
}
@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18n-lite";
interface ResumeReadingButtonProps {
visible: boolean;
onResume: () => void;
}
export function ResumeReadingButton({ visible, onResume }: ResumeReadingButtonProps) {
const { t } = useTranslation();
if (!visible) return null;
return (
<button
type="button"
className="reader-resume-reading"
onClick={onResume}
aria-label={t("reader.resumeReadingAria")}
>
<span className="reader-resume-reading-icon" aria-hidden>
</span>
<span className="reader-resume-reading-label">{t("reader.resumeReading")}</span>
</button>
);
}
@@ -0,0 +1,117 @@
import { useState } from "react";
import { useTranslation } from "react-i18n-lite";
import { useAnnotations } from "@/context/AnnotationsContext";
import { useToast } from "@/hooks/useToast";
import { getApiErrorMessage } from "@/api/errors";
import type { PendingSelection } from "@/hooks/useEpubSelection";
import { BookmarkColorPicker } from "./BookmarkColorPicker";
import {
highlightBackgroundStyle,
loadLastHighlightColor,
normalizeHighlightColor,
saveLastHighlightColor,
} from "@/constants/bookmarkHighlightColors";
interface SelectionPopoverProps {
ebookId: number;
selection: PendingSelection;
onClose: () => void;
onSaved: () => void;
}
export function SelectionPopover({
ebookId,
selection,
onClose,
onSaved,
}: SelectionPopoverProps) {
const { t } = useTranslation();
const { addMarker } = useAnnotations();
const { showToast } = useToast();
const [thought, setThought] = useState("");
const [highlightColor, setHighlightColor] = useState(loadLastHighlightColor);
const [saving, setSaving] = useState(false);
const quoteText =
selection.locationText.length > 120
? `${selection.locationText.slice(0, 120)}`
: selection.locationText;
const handleColorChange = (color: string) => {
const normalized = normalizeHighlightColor(color);
setHighlightColor(normalized);
saveLastHighlightColor(normalized);
};
const handleSave = async () => {
setSaving(true);
try {
await addMarker({
ebook: ebookId,
epub_cfi: selection.epubCfi,
chapter_index: selection.chapterIndex,
chapter_title: selection.chapterTitle,
location_text: selection.locationText,
content: thought.trim(),
highlight_color: highlightColor,
});
showToast({
message: thought.trim()
? t("annotations.markerSavedWithNote")
: t("annotations.markerSaved"),
variant: "success",
});
onSaved();
onClose();
} catch (err) {
showToast({
message: getApiErrorMessage(err, t("annotations.saveFailed")),
variant: "error",
});
} finally {
setSaving(false);
}
};
return (
<>
<div className="selection-popover-backdrop" onClick={onClose} aria-hidden />
<div
className="selection-popover"
style={{ top: selection.anchorTop, left: selection.anchorLeft }}
role="dialog"
aria-label={t("annotations.saveMarker")}
>
<p className="selection-popover-quote">
<span style={highlightBackgroundStyle(highlightColor)}>&ldquo;{quoteText}&rdquo;</span>
</p>
<BookmarkColorPicker value={highlightColor} onChange={handleColorChange} />
<textarea
className="selection-popover-input"
value={thought}
onChange={(e) => setThought(e.target.value)}
placeholder={t("annotations.thoughtPlaceholder")}
rows={3}
/>
<div className="selection-popover-actions">
<button
type="button"
className="btn btn-sm"
onClick={handleSave}
disabled={saving}
>
{saving ? t("common.saving") : t("annotations.saveMarker")}
</button>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={onClose}
disabled={saving}
>
{t("common.cancel")}
</button>
</div>
</div>
</>
);
}
@@ -1,53 +1,83 @@
/**
* TableOfContents slide-in drawer listing all chapters.
* Tap a chapter to navigate. Current chapter is highlighted.
* TableOfContents slide-in drawer for epub.js navigation tree.
*/
import type { ChapterSummary } from "../../types/reader";
import type { KeyboardEvent } from "react";
import { useTranslation } from "react-i18n-lite";
export interface EpubTocItem {
label: string;
href: string;
subitems?: EpubTocItem[];
}
interface TableOfContentsProps {
chapters: ChapterSummary[];
currentChapterNumber: number;
items: EpubTocItem[];
isOpen: boolean;
onClose: () => void;
onNavigate: (number: number) => void;
onNavigate: (href: string) => void;
}
function TocEntry({
item,
depth,
onNavigate,
}: {
item: EpubTocItem;
depth: number;
onNavigate: (href: string) => void;
}) {
return (
<>
<button
type="button"
className="toc-item"
style={{ paddingLeft: `${16 + depth * 14}px` }}
onClick={() => onNavigate(item.href)}
>
<span className="toc-item-title">{item.label}</span>
</button>
{item.subitems?.map((sub) => (
<TocEntry key={sub.href} item={sub} depth={depth + 1} onNavigate={onNavigate} />
))}
</>
);
}
export default function TableOfContents({
chapters,
currentChapterNumber,
items,
isOpen,
onClose,
onNavigate,
}: TableOfContentsProps) {
const handleChapterClick = (number: number) => {
onNavigate(number);
const { t } = useTranslation();
const handleNavigate = (href: string) => {
onNavigate(href);
onClose();
};
return (
<>
{/* Overlay */}
{isOpen && (
<div
className="toc-overlay"
onClick={onClose}
onKeyDown={(e: React.KeyboardEvent) => {
onKeyDown={(e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
}}
role="presentation"
/>
)}
{/* Drawer */}
<aside className={`toc-drawer ${isOpen ? "toc-drawer--open" : ""}`}>
<div className="toc-header">
<h2 className="toc-title">Contents</h2>
<h2 className="toc-title">{t("reader.tocTitle")}</h2>
<button
type="button"
className="toc-close-btn"
onClick={onClose}
aria-label="Close table of contents"
aria-label={t("reader.closeTocAria")}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
@@ -57,24 +87,15 @@ export default function TableOfContents({
</div>
<nav className="toc-list">
{chapters.length === 0 && (
<p className="toc-empty">No chapters available.</p>
{items.length === 0 ? (
<p className="toc-empty">{t("reader.tocEmpty")}</p>
) : (
items.map((item) => (
<TocEntry key={item.href} item={item} depth={0} onNavigate={handleNavigate} />
))
)}
{chapters.map((chapter) => (
<button
key={chapter.number}
type="button"
className={`toc-item ${
chapter.number === currentChapterNumber ? "toc-item--active" : ""
}`}
onClick={() => handleChapterClick(chapter.number)}
>
<span className="toc-item-number">{chapter.number}</span>
<span className="toc-item-title">{chapter.title}</span>
</button>
))}
</nav>
</aside>
</>
);
}
}
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../../api/books";
import { useDebounce } from "../../hooks/useDebounce";
import type { BookListItem } from "../../types/book";
@@ -13,6 +14,7 @@ interface SearchSuggestionsProps {
}
export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion }: SearchSuggestionsProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [suggestions, setSuggestions] = useState<BookListItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -45,7 +47,6 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
};
}, [debouncedQuery]);
// Close on click outside
useEffect(() => {
if (!visible) return;
const handler = (e: MouseEvent) => {
@@ -53,7 +54,6 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
onClose();
}
};
// Delay attachment to avoid the click that opened us from immediately closing
const timer = setTimeout(() => document.addEventListener("click", handler), 0);
return () => {
clearTimeout(timer);
@@ -61,7 +61,6 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
};
}, [visible, onClose]);
// Close on Escape
useEffect(() => {
if (!visible) return;
const handler = (e: KeyboardEvent) => {
@@ -77,12 +76,12 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
<div ref={containerRef} className={styles.container}>
{loading && (
<div className={styles.infoText}>
Searching...
{t("search.searching")}
</div>
)}
{!loading && suggestions.length === 0 && debouncedQuery.trim() && (
<div className={styles.infoText}>
No quick suggestions
{t("search.noSuggestions")}
</div>
)}
{suggestions.map((book) => (
@@ -110,11 +109,11 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
{book.title}
</div>
<div className={styles.bookAuthor}>
{book.author || "Unknown Author"}
{book.author || t("common.unknownAuthor")}
</div>
</div>
</div>
))}
</div>
);
}
}
@@ -0,0 +1,67 @@
export const DEFAULT_BOOKMARK_HIGHLIGHT_COLOR = "#fde047";
export const BOOKMARK_HIGHLIGHT_PRESETS = [
{ id: "yellow", value: "#fde047", labelKey: "annotations.highlightYellow" },
{ id: "green", value: "#86efac", labelKey: "annotations.highlightGreen" },
{ id: "pink", value: "#f9a8d4", labelKey: "annotations.highlightPink" },
{ id: "blue", value: "#93c5fd", labelKey: "annotations.highlightBlue" },
{ id: "purple", value: "#d8b4fe", labelKey: "annotations.highlightPurple" },
] as const;
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
export function normalizeHighlightColor(value: string | undefined | null): string {
const v = (value ?? "").trim();
return HEX_COLOR_RE.test(v) ? v.toLowerCase() : DEFAULT_BOOKMARK_HIGHLIGHT_COLOR;
}
export function markerPassageStyle(color: string): {
borderLeft: string;
backgroundColor: string;
paddingLeft: number;
margin: string;
borderRadius: number;
} {
const c = normalizeHighlightColor(color);
return {
borderLeft: `4px solid ${c}`,
backgroundColor: `${c}26`,
paddingLeft: 10,
margin: "8px 0",
borderRadius: 4,
};
}
export function highlightBackgroundStyle(color: string): {
backgroundColor: string;
boxDecorationBreak: "clone";
WebkitBoxDecorationBreak: "clone";
padding: string;
borderRadius: number;
} {
return {
backgroundColor: color,
boxDecorationBreak: "clone",
WebkitBoxDecorationBreak: "clone",
padding: "0 2px",
borderRadius: 2,
};
}
export const BOOKMARK_COLOR_STORAGE_KEY = "cloud-reader:lastBookmarkHighlightColor";
export function loadLastHighlightColor(): string {
try {
return normalizeHighlightColor(localStorage.getItem(BOOKMARK_COLOR_STORAGE_KEY));
} catch {
return DEFAULT_BOOKMARK_HIGHLIGHT_COLOR;
}
}
export function saveLastHighlightColor(color: string): void {
try {
localStorage.setItem(BOOKMARK_COLOR_STORAGE_KEY, normalizeHighlightColor(color));
} catch {
/* ignore */
}
}
+42 -180
View File
@@ -3,130 +3,76 @@ import {
useContext,
useReducer,
useCallback,
useMemo,
type ReactNode,
} from "react";
import type { Bookmark, Note, AnnotationEntry } from "@/types";
import type { Bookmark, CreateMarkerPayload, MarkerEntry, MarkersByBook } from "@/types";
import * as annotationsApi from "@/api/annotations";
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
import { bookmarkToMarkerEntry, groupMarkersByBook, sortMarkers } from "@/utils/markers";
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
// ---------------------------------------------------------------------------
| { type: "ADD_BOOKMARK"; payload: Bookmark };
function annotationsReducer(
state: AnnotationsState,
action: AnnotationsAction
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 "FETCH_BOOKMARKS_SUCCESS": {
const sorted = [...action.payload].sort((a, b) => {
if (a.chapter_index !== b.chapter_index) return a.chapter_index - b.chapter_index;
return a.epub_cfi.localeCompare(b.epub_cfi);
});
return { ...state, bookmarks: sorted, bookmarksLoading: false };
}
case "SET_ERROR":
return { ...state, error: action.payload, bookmarksLoading: false, notesLoading: false };
case "CLEAR_ERROR":
return { ...state, error: null };
return { ...state, error: action.payload, bookmarksLoading: false };
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 };
case "ADD_BOOKMARK": {
const next = [...state.bookmarks, action.payload].sort((a, b) => {
if (a.chapter_index !== b.chapter_index) return a.chapter_index - b.chapter_index;
return a.epub_cfi.localeCompare(b.epub_cfi);
});
return { ...state, bookmarks: next };
}
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>;
markers: MarkerEntry[];
markersByBook: MarkersByBook[];
loadBookmarks: (ebookId?: string | number) => Promise<void>;
addMarker: (data: CreateMarkerPayload) => Promise<Bookmark>;
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,
}: {
@@ -134,10 +80,10 @@ export function AnnotationsProvider({
}): React.ReactElement {
const [state, dispatch] = useReducer(annotationsReducer, initialState);
const loadBookmarks = useCallback(async (bookId?: string) => {
const loadBookmarks = useCallback(async (ebookId?: string | number) => {
dispatch({ type: "FETCH_BOOKMARKS_START" });
try {
const response = await annotationsApi.fetchBookmarks(bookId);
const response = await annotationsApi.fetchBookmarks(ebookId);
dispatch({ type: "FETCH_BOOKMARKS_SUCCESS", payload: response.results });
} catch (err) {
dispatch({
@@ -147,112 +93,34 @@ export function AnnotationsProvider({
}
}, []);
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 addMarker = useCallback(async (data: CreateMarkerPayload): Promise<Bookmark> => {
const bookmark = await annotationsApi.createMarker(data);
dispatch({ type: "ADD_BOOKMARK", payload: bookmark });
return bookmark;
}, []);
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 markers = useMemo(
() => state.bookmarks.map(bookmarkToMarkerEntry).sort(sortMarkers),
[state.bookmarks],
);
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 markersByBook = useMemo(
() => groupMarkersByBook(state.bookmarks),
[state.bookmarks],
);
const value: AnnotationsContextValue = {
state,
markers,
markersByBook,
loadBookmarks,
loadNotes,
addBookmark,
addNote,
editNote,
addMarker,
removeBookmark,
removeNote,
setSelectedBook,
mergedAnnotations,
};
return (
@@ -262,16 +130,10 @@ export function AnnotationsProvider({
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useAnnotations(): AnnotationsContextValue {
const context = useContext(AnnotationsContext);
if (!context) {
throw new Error(
"useAnnotations must be used within an AnnotationsProvider"
);
throw new Error("useAnnotations must be used within an AnnotationsProvider");
}
return context;
}
}
-129
View File
@@ -1,129 +0,0 @@
/**
* useChapters fetch chapter list and manage current chapter navigation.
*/
import { useCallback, useEffect, useState } from "react";
import { getChapterContent, getChapters } from "../api/reader";
import type { ChapterDetail, ChapterSummary } from "../types/reader";
export interface UseChaptersReturn {
chapters: ChapterSummary[];
currentChapter: ChapterDetail | null;
currentChapterNumber: number;
isLoading: boolean;
error: string | null;
navigateToChapter: (number: number) => Promise<void>;
goToNextChapter: () => Promise<void>;
goToPreviousChapter: () => Promise<void>;
hasNext: boolean;
hasPrevious: boolean;
}
export function useChapters(
bookId: number,
initialChapter: number = 1
): UseChaptersReturn {
const [chapters, setChapters] = useState<ChapterSummary[]>([]);
const [currentChapter, setCurrentChapter] = useState<ChapterDetail | null>(
null
);
const [currentChapterNumber, setCurrentChapterNumber] =
useState<number>(initialChapter);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch chapter list on mount
useEffect(() => {
let cancelled = false;
setIsLoading(true);
getChapters(bookId)
.then((data) => {
if (!cancelled) {
setChapters(data);
// If chapters exist and initial chapter is valid, fetch it
if (
data.length > 0 &&
data.some((c) => c.number === currentChapterNumber)
) {
return getChapterContent(bookId, currentChapterNumber);
}
return null;
}
return null;
})
.then((chapter) => {
if (!cancelled && chapter) {
setCurrentChapter(chapter);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load chapters"
);
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [bookId, currentChapterNumber]);
const fetchChapter = useCallback(
async (number: number) => {
setIsLoading(true);
setError(null);
try {
const chapter = await getChapterContent(bookId, number);
setCurrentChapter(chapter);
setCurrentChapterNumber(number);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to load chapter"
);
} finally {
setIsLoading(false);
}
},
[bookId]
);
const navigateToChapter = useCallback(
(number: number) => {
fetchChapter(number);
},
[fetchChapter]
);
const goToNextChapter = useCallback(() => {
const next = currentChapterNumber + 1;
if (chapters.some((c) => c.number === next)) {
fetchChapter(next);
}
}, [currentChapterNumber, chapters, fetchChapter]);
const goToPreviousChapter = useCallback(() => {
const prev = currentChapterNumber - 1;
if (prev >= 1 && chapters.some((c) => c.number === prev)) {
fetchChapter(prev);
}
}, [currentChapterNumber, chapters, fetchChapter]);
const hasNext = chapters.some((c) => c.number === currentChapterNumber + 1);
const hasPrevious = chapters.some((c) => c.number === currentChapterNumber - 1);
return {
chapters,
currentChapter,
currentChapterNumber,
isLoading,
error,
navigateToChapter,
goToNextChapter,
goToPreviousChapter,
hasNext,
hasPrevious,
};
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useMemo, useRef } from "react";
import { useAnnotations } from "@/context/AnnotationsContext";
import type { EpubRendition } from "@/utils/epubRendition";
import {
syncBookmarkHighlights,
type EpubRenditionWithHighlights,
} from "@/utils/epubHighlights";
export function useEpubHighlights(
ebookId: number,
renditionRef: React.RefObject<EpubRendition | null>,
renditionVersion: number,
): void {
const { markers, loadBookmarks } = useAnnotations();
const appliedRef = useRef<Map<string, string>>(new Map());
const bookMarkers = useMemo(
() => markers.filter((m) => m.ebook_id === ebookId),
[markers, ebookId],
);
useEffect(() => {
if (!ebookId || Number.isNaN(ebookId)) return;
void loadBookmarks(ebookId);
}, [ebookId, loadBookmarks]);
useEffect(() => {
appliedRef.current.clear();
}, [ebookId, renditionVersion]);
useEffect(() => {
const rendition = renditionRef.current as EpubRenditionWithHighlights | null;
if (!rendition) return;
const sync = () => {
syncBookmarkHighlights(rendition, bookMarkers, appliedRef.current);
};
sync();
const onRelocated = () => sync();
rendition.on?.("relocated", onRelocated);
rendition.on?.("rendered", onRelocated);
return () => {
rendition.off?.("relocated", onRelocated);
rendition.off?.("rendered", onRelocated);
};
}, [bookMarkers, renditionRef, renditionVersion]);
}
+393
View File
@@ -0,0 +1,393 @@
/**
* useEpubReader load EPUB blob, manage CFI location, and persist reading progress.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { booksApi } from "../api/books";
import { getReadingProgress, updateReadingProgress } from "../api/reader";
import type { ReadingProgress, ReadingSettings } from "../types/reader";
import {
applyRenditionSettings,
isEpubCfi,
marginWidthToGap,
resolveSpineHref,
updateChapterTitleFromRendition,
type EpubRendition,
} from "../utils/epubRendition";
type EpubRenditionWithManager = EpubRendition & {
hooks?: {
content?: {
register: (fn: (contents: { document: Document }) => void) => void;
};
};
};
export interface ReadingAnchor {
cfi: string;
percentage: number;
}
function percentageFromCfi(rendition: EpubRendition, cfi: string): number {
if (!rendition.book?.locations) return 0;
const pct = rendition.book.locations.percentageFromCfi(cfi);
if (pct === null || pct === undefined) return 0;
return Math.round(pct * 100);
}
function cfiFromRendition(rendition: EpubRendition | null): string | null {
if (!rendition) return null;
const start = (rendition as EpubRendition & { location?: { start?: { cfi?: string } } }).location
?.start;
const cfi = start?.cfi;
return cfi && isEpubCfi(cfi) ? cfi : null;
}
export interface UseEpubReaderReturn {
epubUrl: string | null;
location: string | number;
chapterTitle: string;
progress: ReadingProgress | null;
isLoading: boolean;
error: string | null;
isBookmarkPeekActive: boolean;
readingAnchor: ReadingAnchor | null;
handleLocationChanged: (loc: string) => void;
handleGetRendition: (rendition: EpubRendition) => void;
navigateToHref: (href: string) => void;
beginBookmarkPeek: (targetCfi: string) => void;
resumeReadingAnchor: () => void;
goToNextPage: () => void;
goToPrevPage: () => void;
applySettings: (settings: ReadingSettings) => void;
renditionRef: React.RefObject<EpubRendition | null>;
epubOptions: {
flow: "paginated";
manager: "default";
spread: "none";
width: string;
height: string;
gap: number;
};
}
export function useEpubReader(
bookId: number,
marginWidth: number,
initialEpubLocation?: string,
): UseEpubReaderReturn {
const [epubUrl, setEpubUrl] = useState<string | null>(null);
const [location, setLocation] = useState<string | number>(0);
const [chapterTitle, setChapterTitle] = useState("");
const [progress, setProgress] = useState<ReadingProgress | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isBookmarkPeekActive, setIsBookmarkPeekActive] = useState(false);
const [readingAnchor, setReadingAnchor] = useState<ReadingAnchor | null>(null);
const renditionRef = useRef<EpubRendition | null>(null);
const settingsRef = useRef<ReadingSettings | null>(null);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPersistRef = useRef<{ cfi: string; percentage: number } | null>(null);
const savedProgressPercentRef = useRef<number | null>(null);
const isBookmarkPeekRef = useRef(false);
const readingAnchorRef = useRef<ReadingAnchor | null>(null);
const epubOptions = {
flow: "paginated" as const,
manager: "default" as const,
spread: "none" as const,
width: "100%",
height: "100%",
gap: marginWidthToGap(marginWidth),
};
const clearPeekState = useCallback(() => {
isBookmarkPeekRef.current = false;
readingAnchorRef.current = null;
setIsBookmarkPeekActive(false);
setReadingAnchor(null);
}, []);
const setPeekState = useCallback((anchor: ReadingAnchor) => {
readingAnchorRef.current = anchor;
isBookmarkPeekRef.current = true;
setReadingAnchor(anchor);
setIsBookmarkPeekActive(true);
}, []);
const flushProgress = useCallback(
async (cfi: string, percentage: number) => {
if (isBookmarkPeekRef.current) return;
try {
const patch: Parameters<typeof updateReadingProgress>[1] = { epub_location: cfi };
if (percentage > 0) {
patch.percentage = percentage;
patch.current_position = percentage;
}
const updated = await updateReadingProgress(bookId, patch);
setProgress(updated);
if (percentage > 0) savedProgressPercentRef.current = percentage;
} catch {
/* silent — progress save is best-effort */
}
},
[bookId],
);
const scheduleProgress = useCallback(
(cfi: string, percentage: number) => {
if (isBookmarkPeekRef.current) return;
pendingPersistRef.current = { cfi, percentage };
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
const pending = pendingPersistRef.current;
if (pending) void flushProgress(pending.cfi, pending.percentage);
}, 800);
},
[flushProgress],
);
const captureAnchorFromCurrent = useCallback((): ReadingAnchor | null => {
const rendition = renditionRef.current;
let cfi: string | null = null;
if (typeof location === "string" && isEpubCfi(location)) {
cfi = location;
} else {
cfi = cfiFromRendition(rendition);
}
if (!cfi) return null;
const percentage = rendition ? percentageFromCfi(rendition, cfi) : 0;
return { cfi, percentage };
}, [location]);
const beginBookmarkPeek = useCallback(
(targetCfi: string) => {
if (!isEpubCfi(targetCfi)) return;
if (!isBookmarkPeekRef.current) {
let anchor = captureAnchorFromCurrent();
if (!anchor && progress?.epub_location && isEpubCfi(progress.epub_location)) {
const pct = progress.percentage ?? progress.current_position ?? 0;
anchor = { cfi: progress.epub_location, percentage: pct > 0 ? Math.round(pct) : 0 };
}
if (anchor) setPeekState(anchor);
}
setLocation(targetCfi);
},
[captureAnchorFromCurrent, progress, setPeekState],
);
const resumeReadingAnchor = useCallback(() => {
const anchor = readingAnchorRef.current;
clearPeekState();
if (!anchor?.cfi || !isEpubCfi(anchor.cfi)) return;
setLocation(anchor.cfi);
const rendition = renditionRef.current;
const pct = rendition ? percentageFromCfi(rendition, anchor.cfi) : anchor.percentage;
void flushProgress(anchor.cfi, pct > 0 ? pct : anchor.percentage);
}, [clearPeekState, flushProgress]);
useEffect(() => {
let blobUrl: string | null = null;
let cancelled = false;
savedProgressPercentRef.current = null;
pendingPersistRef.current = null;
clearPeekState();
async function loadEpub() {
setIsLoading(true);
setError(null);
try {
const [progressData, blob] = await Promise.all([
getReadingProgress(bookId).catch(() => null),
booksApi.getEpubFile(bookId),
]);
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
setEpubUrl(blobUrl);
const savedPct = progressData?.current_position ?? progressData?.percentage ?? 0;
savedProgressPercentRef.current =
typeof savedPct === "number" && savedPct > 0 ? savedPct : null;
const bookmarkPeekCfi =
initialEpubLocation && isEpubCfi(initialEpubLocation) ? initialEpubLocation : null;
const savedCfi =
progressData?.epub_location && isEpubCfi(progressData.epub_location)
? progressData.epub_location
: null;
if (bookmarkPeekCfi && savedCfi) {
const anchorPct =
typeof savedPct === "number" && savedPct > 0 ? Math.round(savedPct) : 0;
setPeekState({ cfi: savedCfi, percentage: anchorPct });
setProgress(progressData);
setLocation(bookmarkPeekCfi);
} else if (bookmarkPeekCfi) {
setLocation(bookmarkPeekCfi);
} else if (savedCfi) {
setProgress(progressData);
setLocation(savedCfi);
} else if (progressData) {
setProgress(progressData);
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load EPUB");
}
} finally {
if (!cancelled) setIsLoading(false);
}
}
void loadEpub();
return () => {
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
if (debounceTimer.current) clearTimeout(debounceTimer.current);
if (!isBookmarkPeekRef.current) {
const pending = pendingPersistRef.current;
if (pending) void flushProgress(pending.cfi, pending.percentage);
}
clearPeekState();
};
}, [bookId, initialEpubLocation, flushProgress, clearPeekState, setPeekState]);
const handleLocationChanged = useCallback(
(loc: string) => {
setLocation(loc);
if (!isEpubCfi(loc)) return;
const rendition = renditionRef.current;
const percentage = rendition ? percentageFromCfi(rendition, loc) : 0;
if (rendition) {
void updateChapterTitleFromRendition(rendition).then((title) => {
if (title) setChapterTitle(title);
});
}
scheduleProgress(loc, percentage);
},
[scheduleProgress],
);
const navigateToHref = useCallback((href: string) => {
const rendition = renditionRef.current;
if (!rendition) {
setLocation(href);
return;
}
void rendition.book.ready.then(() => {
const target = resolveSpineHref(rendition.book, href);
setLocation(target);
return rendition.display(target);
});
}, []);
const syncProgressFromRendition = useCallback(
(attempt = 0) => {
if (isBookmarkPeekRef.current) return;
const rendition = renditionRef.current;
if (!rendition) return;
const start = (rendition as EpubRendition & { location?: { start?: { cfi?: string } } }).location
?.start;
const cfi = start?.cfi;
if (!cfi || !isEpubCfi(cfi)) return;
const percentage = percentageFromCfi(rendition, cfi);
void flushProgress(cfi, percentage);
if (percentage <= 0 && attempt < 2) {
setTimeout(() => syncProgressFromRendition(attempt + 1), attempt === 0 ? 600 : 1500);
}
},
[flushProgress],
);
const handleGetRendition = useCallback((rendition: EpubRendition) => {
const r = rendition as EpubRenditionWithManager;
renditionRef.current = rendition;
const resizeReader = () => {
const container = document.querySelector(".reader-epub-container");
if (container && "resize" in rendition) {
(rendition as EpubRendition & { resize: (w: number, h: number) => void }).resize(
container.clientWidth,
container.clientHeight,
);
}
};
r.hooks?.content?.register?.((contents: { document: Document }) => {
if (contents.document?.body) {
contents.document.body.style.userSelect = "text";
contents.document.body.style.webkitUserSelect = "text";
}
});
void r.book.ready
.then(() => r.book.locations.generate(1600))
.then(resizeReader)
.then(() => updateChapterTitleFromRendition(r))
.then((title) => {
if (title) setChapterTitle(title);
})
.then(() => syncProgressFromRendition());
window.addEventListener("resize", resizeReader);
if (settingsRef.current) {
applyRenditionSettings(rendition, settingsRef.current);
}
return () => window.removeEventListener("resize", resizeReader);
}, [syncProgressFromRendition]);
const applySettings = useCallback((settings: ReadingSettings) => {
settingsRef.current = settings;
if (renditionRef.current) {
applyRenditionSettings(renditionRef.current, settings);
}
}, []);
const goToNextPage = useCallback(() => {
renditionRef.current?.next();
}, []);
const goToPrevPage = useCallback(() => {
renditionRef.current?.prev();
}, []);
return {
epubUrl,
location,
chapterTitle,
progress,
isLoading,
error,
isBookmarkPeekActive,
readingAnchor,
handleLocationChanged,
handleGetRendition,
navigateToHref,
beginBookmarkPeek,
resumeReadingAnchor,
goToNextPage,
goToPrevPage,
applySettings,
epubOptions,
renditionRef,
};
}
+120
View File
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { EpubRendition } from "../utils/epubRendition";
import { isEpubCfi } from "../utils/epubRendition";
export interface PendingSelection {
epubCfi: string;
locationText: string;
chapterIndex: number;
chapterTitle: string;
anchorTop: number;
anchorLeft: number;
}
type EpubContents = {
document: Document;
window?: Window;
cfiFromRange?: (range: Range) => string;
};
type EpubRenditionWithHooks = EpubRendition & {
on?: (event: string, handler: (cfiRange: unknown, contents: EpubContents) => void) => void;
off?: (event: string, handler: (cfiRange: unknown, contents: EpubContents) => void) => void;
location?: { start?: { index?: number; cfi?: string } };
hooks?: {
content?: {
register: (fn: (contents: EpubContents) => void) => void;
};
};
};
function extractSelection(
contents: EpubContents,
rendition: EpubRenditionWithHooks,
): PendingSelection | null {
const text = contents.window?.getSelection?.()?.toString?.()?.trim()
?? contents.document?.getSelection?.()?.toString?.()?.trim()
?? "";
if (!text) return null;
const sel = contents.document.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
let cfi = "";
try {
cfi = contents.cfiFromRange?.(range) ?? "";
} catch {
cfi = "";
}
if (!cfi || !isEpubCfi(cfi)) {
const locCfi = rendition.location?.start?.cfi;
if (locCfi && isEpubCfi(locCfi)) cfi = locCfi;
else return null;
}
const rect = range.getBoundingClientRect();
const chapterIndex = rendition.location?.start?.index ?? 0;
return {
epubCfi: cfi,
locationText: text.slice(0, 2000),
chapterIndex: typeof chapterIndex === "number" ? chapterIndex : 0,
chapterTitle: "",
anchorTop: rect.bottom + 8,
anchorLeft: Math.min(rect.left, window.innerWidth - 280),
};
}
export function useEpubSelection(
renditionRef: React.RefObject<EpubRendition | null>,
chapterTitle: string,
renditionVersion: number,
) {
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
const chapterTitleRef = useRef(chapterTitle);
chapterTitleRef.current = chapterTitle;
const clearSelection = useCallback(() => {
setPendingSelection(null);
}, []);
useEffect(() => {
const rendition = renditionRef.current as EpubRenditionWithHooks | null;
if (!rendition) return;
const applySelection = (partial: PendingSelection | null) => {
if (!partial) {
setPendingSelection(null);
return;
}
setPendingSelection({
...partial,
chapterTitle: chapterTitleRef.current,
});
};
const onSelected = (_cfiRange: unknown, contents: EpubContents) => {
applySelection(extractSelection(contents, rendition));
};
if (rendition.on) {
rendition.on("selected", onSelected);
}
rendition.hooks?.content?.register?.((contents: EpubContents) => {
const handler = () => {
window.setTimeout(() => {
applySelection(extractSelection(contents, rendition));
}, 10);
};
contents.document.addEventListener("mouseup", handler);
});
return () => {
rendition.off?.("selected", onSelected);
};
}, [renditionRef, renditionVersion]);
return { pendingSelection, clearSelection };
}
-99
View File
@@ -1,99 +0,0 @@
/**
* useReadingProgress fetch and update reading progress for a book.
* Auto-saves when chapter or position changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { getReadingProgress, updateReadingProgress } from "../api/reader";
import type { ReadingProgress } from "../types/reader";
export interface UseReadingProgressReturn {
progress: ReadingProgress | null;
isLoading: boolean;
error: string | null;
saveProgress: (
chapter: number,
position: number,
percentage: number
) => Promise<void>;
/** Schedule a debounced save — fires at most once per 3 seconds */
debouncedSave: (
chapter: number,
position: number,
percentage: number
) => void;
}
export function useReadingProgress(
bookId: number
): UseReadingProgressReturn {
const [progress, setProgress] = useState<ReadingProgress | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fetch progress on mount
useEffect(() => {
let cancelled = false;
setIsLoading(true);
getReadingProgress(bookId)
.then((data) => {
if (!cancelled) setProgress(data);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load progress"
);
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [bookId]);
const saveProgress = useCallback(
async (chapter: number, position: number, percentage: number) => {
try {
const updated = await updateReadingProgress(bookId, {
current_chapter: chapter,
current_position: position,
percentage,
});
setProgress(updated);
setError(null);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to save progress"
);
}
},
[bookId]
);
const debouncedSave = useCallback(
(chapter: number, position: number, percentage: number) => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
saveProgress(chapter, position, percentage);
}, 3000);
},
[saveProgress]
);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, []);
return { progress, isLoading, error, saveProgress, debouncedSave };
}
+91 -27
View File
@@ -1,15 +1,25 @@
/**
* useReadingSettings fetch and manage user reading preferences.
* Applies settings as CSS custom properties on the document root.
* Preview applies instantly to the reader; API saves are debounced for sliders.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getReadingSettings,
updateReadingSettings,
} from "../api/reader";
import type { ReadingSettings } from "../types/reader";
const THEME_COLORS: Record<
ReadingSettings["theme"],
Pick<ReadingSettings, "background_color" | "text_color">
> = {
sepia: { background_color: "#f5f0eb", text_color: "#1a1a1a" },
dark: { background_color: "#1a1a2e", text_color: "#e0e0e0" },
light: { background_color: "#ffffff", text_color: "#1a1a1a" },
paper: { background_color: "#e8e0d4", text_color: "#2c2c2c" },
};
const DEFAULT_SETTINGS: ReadingSettings = {
font_family: "serif",
font_size: 18,
@@ -24,22 +34,38 @@ const DEFAULT_SETTINGS: ReadingSettings = {
updated_at: "",
};
function applyCssVariables(settings: ReadingSettings): void {
const SAVE_DEBOUNCE_MS = 600;
export function applyReadingCssVariables(settings: ReadingSettings): void {
const root = document.documentElement;
root.style.setProperty("--reader-bg", settings.background_color);
root.style.setProperty("--reader-text", settings.text_color);
root.style.setProperty("--reader-font-family", settings.font_family);
root.style.setProperty("--reader-font-size", `${settings.font_size}px`);
root.style.setProperty("--reader-line-height", String(settings.line_height));
root.style.setProperty("--reader-margin", `${settings.margin_width}px`);
root.style.setProperty("--reader-brightness", `${settings.brightness}%`);
}
function mergeSettings(
prev: ReadingSettings,
partial: Partial<ReadingSettings>,
): ReadingSettings {
const next = { ...prev, ...partial };
if (partial.theme && THEME_COLORS[partial.theme]) {
Object.assign(next, THEME_COLORS[partial.theme]);
}
return next;
}
export type SettingsPersistMode = "debounced" | "immediate";
export interface UseReadingSettingsReturn {
settings: ReadingSettings;
isLoading: boolean;
error: string | null;
updateSettings: (partial: Partial<ReadingSettings>) => Promise<void>;
updateSettings: (
partial: Partial<ReadingSettings>,
persist?: SettingsPersistMode,
) => void;
flushSettings: () => Promise<void>;
}
export function useReadingSettings(): UseReadingSettingsReturn {
@@ -47,7 +73,45 @@ export function useReadingSettings(): UseReadingSettingsReturn {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch settings on mount
const pendingPatch = useRef<Partial<ReadingSettings>>({});
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saving = useRef(false);
const runPersist = useCallback(async () => {
const payload = { ...pendingPatch.current };
pendingPatch.current = {};
if (Object.keys(payload).length === 0 || saving.current) return;
saving.current = true;
try {
const updated = await updateReadingSettings(payload);
setSettings(updated);
applyReadingCssVariables(updated);
setError(null);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to update reading settings",
);
} finally {
saving.current = false;
}
}, []);
const schedulePersist = useCallback(
(partial: Partial<ReadingSettings>, immediate: boolean) => {
Object.assign(pendingPatch.current, partial);
if (saveTimer.current) clearTimeout(saveTimer.current);
if (immediate) {
void runPersist();
return;
}
saveTimer.current = setTimeout(() => {
void runPersist();
}, SAVE_DEBOUNCE_MS);
},
[runPersist],
);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
@@ -55,16 +119,15 @@ export function useReadingSettings(): UseReadingSettingsReturn {
.then((data) => {
if (!cancelled) {
setSettings(data);
applyCssVariables(data);
applyReadingCssVariables(data);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load reading settings"
err instanceof Error ? err.message : "Failed to load reading settings",
);
// Apply defaults
applyCssVariables(DEFAULT_SETTINGS);
applyReadingCssVariables(DEFAULT_SETTINGS);
}
})
.finally(() => {
@@ -72,25 +135,26 @@ export function useReadingSettings(): UseReadingSettingsReturn {
});
return () => {
cancelled = true;
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
const updateSettings = useCallback(
async (partial: Partial<ReadingSettings>) => {
try {
const updated = await updateReadingSettings(partial);
setSettings(updated);
applyCssVariables(updated);
setError(null);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to update reading settings"
);
throw err;
}
(partial: Partial<ReadingSettings>, persist: SettingsPersistMode = "debounced") => {
setSettings((prev) => {
const next = mergeSettings(prev, partial);
applyReadingCssVariables(next);
return next;
});
schedulePersist(partial, persist === "immediate");
},
[]
[schedulePersist],
);
return { settings, isLoading, error, updateSettings };
}
const flushSettings = useCallback(async () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
await runPersist();
}, [runPersist]);
return { settings, isLoading, error, updateSettings, flushSettings };
}
+66
View File
@@ -0,0 +1,66 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { ToastContainer } from "../components/ToastContainer";
export type ToastVariant = "success" | "error" | "warning";
export interface ToastItem {
id: string;
message: string;
variant: ToastVariant;
}
interface ShowToastOptions {
message: string;
variant?: ToastVariant;
durationMs?: number;
}
interface ToastContextValue {
showToast: (options: ShowToastOptions) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const DEFAULT_DURATION_MS = 4000;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const dismissToast = useCallback((id: string) => {
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const showToast = useCallback(
({ message, variant = "success", durationMs = DEFAULT_DURATION_MS }: ShowToastOptions) => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, variant }]);
const timer = setTimeout(() => dismissToast(id), durationMs);
timersRef.current.set(id, timer);
},
[dismissToast],
);
const value = useMemo(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastContainer toasts={toasts} />
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast must be used within ToastProvider");
}
return ctx;
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { TranslationContainer, useTranslation } from "react-i18n-lite";
import { LOCALE_STORAGE_KEY, locales, resolveDefaultLanguage, type SupportedLanguage } from "../locales";
function LanguagePersistence() {
const { language, setLanguage } = useTranslation();
useEffect(() => {
const saved = localStorage.getItem(LOCALE_STORAGE_KEY) as SupportedLanguage | null;
if (saved === "en-US" || saved === "es-ES") {
setLanguage(saved);
}
}, [setLanguage]);
useEffect(() => {
localStorage.setItem(LOCALE_STORAGE_KEY, language);
document.documentElement.lang = language.startsWith("es") ? "es" : "en";
}, [language]);
return null;
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
return (
<TranslationContainer locales={locales} defaultLanguage={resolveDefaultLanguage()}>
<LanguagePersistence />
{children}
</TranslationContainer>
);
}
+240
View File
@@ -0,0 +1,240 @@
const enUS = {
common: {
loading: "Loading...",
back: "Back",
save: "Save",
saving: "Saving...",
retry: "Retry",
dismiss: "Dismiss",
cancel: "Cancel",
delete: "Delete",
deleting: "Deleting...",
edit: "Edit",
unknownError: "Something went wrong",
unknownAuthor: "Unknown Author",
appName: "Cloud Reader",
},
auth: {
signIn: "Sign In",
createAccount: "Create Account",
email: "Email",
password: "Password",
confirmPassword: "Confirm Password",
emailPlaceholder: "you@example.com",
passwordPlaceholder: "At least 8 characters",
confirmPasswordPlaceholder: "Repeat your password",
signingIn: "Signing in...",
creatingAccount: "Creating account...",
noAccount: "Don't have an account?",
hasAccount: "Already have an account?",
register: "Register",
passwordsMismatch: "Passwords do not match.",
loginFailed: "Login failed",
registrationFailed: "Registration failed",
},
library: {
title: "Library",
bookCountOne: "{{count}} book",
bookCountMany: "{{count}} books",
addBook: "Add Book",
bookmarks: "Bookmarks",
settings: "Settings",
logout: "Logout",
searchPlaceholder: "Search by title, author, or genre...",
voiceSearchTitle: "Search with voice",
voiceSearchStopTitle: "Stop listening",
filtersShow: "Filters",
filtersHide: "Filters",
filtersLabel: "Filters",
genre: "Genre",
author: "Author",
status: "Status",
allGenres: "All Genres",
allAuthors: "All Authors",
allStatuses: "All Statuses",
clearFilters: "Clear",
loadFailed: "Failed to load books",
listening: "Listening... speak now",
stop: "Stop",
voiceNoSpeech: "No speech detected. Try again.",
voiceError: "Voice search: {{message}}",
pdfNotice: "PDF reading is not supported in the web reader. Upload an EPUB to read here.",
emptyTitle: "Your library is empty",
emptyFilteredTitle: "No books found",
emptyDescription: "Add a book to get started building your collection.",
emptyFilteredDescription: "Try adjusting your search query or filters to discover more books.",
clearAllFilters: "Clear All Filters",
addFirstBook: "Add Your First Book",
readingStatus: {
wantToRead: "Want to Read",
reading: "Reading",
readingWithProgress: "Reading · {{percent}}%",
finished: "Finished",
dnf: "Did Not Finish",
uploaded: "Uploaded",
},
finishedSection: "Finished",
finishedCount: "{{count}} finished",
finishedCountShort: "{{count}} finished",
allInFinishedShelf: "Your finished books are in the panel below. Tap to expand.",
},
contextMenu: {
syncMetadata: "Sync metadata",
syncingMetadata: "Syncing metadata…",
remove: "Remove",
removing: "Removing…",
confirmRemove: 'Remove "{{title}}" from your library?',
},
toast: {
metadataSynced: 'Metadata synced for "{{title}}"',
noMatch: "No Open Library match found",
refreshDone: "Metadata refresh completed",
removed: '"{{title}}" removed from your library',
},
settings: {
title: "Reading Settings",
loading: "Loading settings...",
loadFailed: "Failed to load settings",
saveFailed: "Failed to save settings",
saved: "Settings saved!",
uiLanguage: "UI Language",
uiLanguageEn: "English",
uiLanguageEs: "Español",
fontSize: "Font Size ({{size}}px)",
fontStyle: "Font Style",
backgroundColor: "Background Color",
fontSans: "Sans Serif",
fontSerif: "Serif",
fontMonospace: "Monospace",
bgWhite: "White",
bgSepia: "Sepia",
bgDark: "Dark",
bgGreen: "Green",
saveSettings: "Save Settings",
},
reader: {
loading: "Loading reader...",
reading: "Reading",
loadBookFailed: "Failed to load book details",
epubOnly: "PDF reading is not supported in the web reader. EPUB only.",
unableToOpen: "Unable to open this book.",
backToLibrary: "Back to library",
prevPage: "Previous page",
nextPage: "Next page",
resumeReading: "Back to reading",
resumeReadingAria: "Return to where you were reading",
backToLibraryAria: "Back to library",
tocAria: "Table of contents",
settingsAria: "Reading settings",
settingsTitle: "Reading Settings",
closeSettingsAria: "Close settings",
theme: "Theme",
font: "Font",
fontSize: "Font Size: {{size}}px",
lineHeight: "Line Height: {{value}}",
margins: "Margins: {{value}}px",
brightness: "Brightness: {{value}}%",
orientation: "Orientation",
themeSepia: "Sepia",
themeDark: "Dark",
themeLight: "Light",
themePaper: "Paper",
fontSans: "Sans-serif",
fontSerif: "Serif",
fontMonospace: "Monospace",
orientationAuto: "Auto",
orientationPortrait: "Portrait",
orientationLandscape: "Landscape",
fontSizeAria: "Font size",
lineHeightAria: "Line height",
marginWidthAria: "Margin width",
brightnessAria: "Brightness",
tocTitle: "Contents",
closeTocAria: "Close table of contents",
tocEmpty: "No chapters available.",
},
addBook: {
title: "Add Book",
fileLabel: "File (EPUB or PDF) *",
selected: "Selected: {{name}}",
titleLabel: "Title *",
titlePlaceholder: "Book title",
authorLabel: "Author",
authorPlaceholder: "Author name",
uploading: "Uploading...",
upload: "Upload Book",
fileTypeError: "Only EPUB and PDF files are supported.",
requiredError: "Title and file are required.",
uploadFailed: "Upload failed.",
},
bookDetail: {
backToLibrary: "Back to Library",
back: "Back",
notFound: "Book not found",
notFoundDescription: "The book you're looking for doesn't exist or has been removed.",
invalidId: "Invalid book ID",
loadFailed: "Failed to load book details",
byAuthor: "by {{author}}",
pages: "{{count}} pages",
description: "Description",
added: "Added {{date}}",
updated: "Updated {{date}}",
},
search: {
searching: "Searching...",
noSuggestions: "No quick suggestions",
},
annotations: {
title: "Bookmarks & Notes",
loading: "Loading markers...",
empty: "No bookmarks or notes yet.",
bookmarkCount: "{{count}} bookmarks",
noteCount: "{{count}} notes",
markerCount: "{{count}} markers",
bookmark: "Bookmark",
note: "Note",
bookmarkOnly: "Bookmark",
saveMarker: "Save",
markerSaved: "Bookmark saved",
markerSavedWithNote: "Note saved",
thoughtPlaceholder: "Your thought about this passage (optional)",
highlightColor: "Highlight color",
highlightCustom: "Custom color",
highlightYellow: "Yellow",
highlightGreen: "Green",
highlightPink: "Pink",
highlightBlue: "Blue",
highlightPurple: "Purple",
selectTextHint: "Select text in the reader to add a bookmark or note.",
showMore: "Show more",
showLess: "Show less",
goToPassage: "Go to passage",
goToPage: "Go to page",
inBookPanel: "Bookmarks in this book",
chapterIndex: "Chapter {{index}}",
page: "Page {{page}}",
pageShort: "p.{{page}}",
loadingBookmarks: "Loading bookmarks...",
bookmarksEmpty: "No bookmarks yet. Select a passage and add a bookmark while reading.",
loadingNotes: "Loading notes...",
notesEmpty: "No notes yet. Select a passage and add a note while reading.",
addToPage: "Add to Page {{page}}",
addBookmark: "Add Bookmark",
addNote: "Add Note",
noteLabel: "Note:",
notePlaceholder: "Write your note here...",
noteEmpty: "Note content cannot be empty.",
saveFailed: "Failed to save annotation.",
close: "Close",
home: "Home",
bookmarksNotes: "Bookmarks & Notes",
},
} as const;
export default enUS;
type LocaleStrings<T> = {
[K in keyof T]: T[K] extends string ? string : LocaleStrings<T[K]>;
};
export type Locale = LocaleStrings<typeof enUS>;
+236
View File
@@ -0,0 +1,236 @@
import type { Locale } from "./en-US";
const esES: Locale = {
common: {
loading: "Cargando...",
back: "Volver",
save: "Guardar",
saving: "Guardando...",
retry: "Reintentar",
dismiss: "Descartar",
cancel: "Cancelar",
delete: "Eliminar",
deleting: "Eliminando...",
edit: "Editar",
unknownError: "Algo salió mal",
unknownAuthor: "Autor desconocido",
appName: "Cloud Reader",
},
auth: {
signIn: "Iniciar sesión",
createAccount: "Crear cuenta",
email: "Correo electrónico",
password: "Contraseña",
confirmPassword: "Confirmar contraseña",
emailPlaceholder: "tu@ejemplo.com",
passwordPlaceholder: "Al menos 8 caracteres",
confirmPasswordPlaceholder: "Repite tu contraseña",
signingIn: "Iniciando sesión...",
creatingAccount: "Creando cuenta...",
noAccount: "¿No tienes cuenta?",
hasAccount: "¿Ya tienes cuenta?",
register: "Registrarse",
passwordsMismatch: "Las contraseñas no coinciden.",
loginFailed: "Error al iniciar sesión",
registrationFailed: "Error al registrarse",
},
library: {
title: "Biblioteca",
bookCountOne: "{{count}} libro",
bookCountMany: "{{count}} libros",
addBook: "Añadir libro",
bookmarks: "Marcadores",
settings: "Ajustes",
logout: "Cerrar sesión",
searchPlaceholder: "Buscar por título, autor o género...",
voiceSearchTitle: "Buscar con voz",
voiceSearchStopTitle: "Dejar de escuchar",
filtersShow: "Filtros",
filtersHide: "Filtros",
filtersLabel: "Filtros",
genre: "Género",
author: "Autor",
status: "Estado",
allGenres: "Todos los géneros",
allAuthors: "Todos los autores",
allStatuses: "Todos los estados",
clearFilters: "Limpiar",
loadFailed: "Error al cargar los libros",
listening: "Escuchando... habla ahora",
stop: "Detener",
voiceNoSpeech: "No se detectó voz. Inténtalo de nuevo.",
voiceError: "Búsqueda por voz: {{message}}",
pdfNotice: "La lectura de PDF no está disponible en el lector web. Sube un EPUB para leer aquí.",
emptyTitle: "Tu biblioteca está vacía",
emptyFilteredTitle: "No se encontraron libros",
emptyDescription: "Añade un libro para empezar tu colección.",
emptyFilteredDescription: "Prueba a ajustar la búsqueda o los filtros.",
clearAllFilters: "Limpiar todos los filtros",
addFirstBook: "Añade tu primer libro",
readingStatus: {
wantToRead: "Quiero leer",
reading: "Leyendo",
readingWithProgress: "Leyendo · {{percent}}%",
finished: "Terminado",
dnf: "No terminado",
uploaded: "Subido",
},
finishedSection: "Terminados",
finishedCount: "{{count}} terminados",
finishedCountShort: "{{count}} terminados",
allInFinishedShelf: "Tus libros terminados están en el panel inferior. Toca para expandir.",
},
contextMenu: {
syncMetadata: "Sincronizar metadatos",
syncingMetadata: "Sincronizando metadatos…",
remove: "Eliminar",
removing: "Eliminando…",
confirmRemove: '¿Eliminar "{{title}}" de tu biblioteca?',
},
toast: {
metadataSynced: 'Metadatos sincronizados para "{{title}}"',
noMatch: "No se encontró coincidencia en Open Library",
refreshDone: "Actualización de metadatos completada",
removed: '"{{title}}" eliminado de tu biblioteca',
},
settings: {
title: "Ajustes de lectura",
loading: "Cargando ajustes...",
loadFailed: "Error al cargar los ajustes",
saveFailed: "Error al guardar los ajustes",
saved: "¡Ajustes guardados!",
uiLanguage: "Idioma de la interfaz",
uiLanguageEn: "English",
uiLanguageEs: "Español",
fontSize: "Tamaño de fuente ({{size}}px)",
fontStyle: "Estilo de fuente",
backgroundColor: "Color de fondo",
fontSans: "Sans Serif",
fontSerif: "Serif",
fontMonospace: "Monospace",
bgWhite: "Blanco",
bgSepia: "Sepia",
bgDark: "Oscuro",
bgGreen: "Verde",
saveSettings: "Guardar ajustes",
},
reader: {
loading: "Cargando lector...",
reading: "Leyendo",
loadBookFailed: "Error al cargar los detalles del libro",
epubOnly: "La lectura de PDF no está disponible en el lector web. Solo EPUB.",
unableToOpen: "No se puede abrir este libro.",
backToLibrary: "Volver a la biblioteca",
prevPage: "Página anterior",
nextPage: "Página siguiente",
resumeReading: "Volver a la lectura",
resumeReadingAria: "Volver a donde estabas leyendo",
backToLibraryAria: "Volver a la biblioteca",
tocAria: "Tabla de contenidos",
settingsAria: "Ajustes de lectura",
settingsTitle: "Ajustes de lectura",
closeSettingsAria: "Cerrar ajustes",
theme: "Tema",
font: "Fuente",
fontSize: "Tamaño de fuente: {{size}}px",
lineHeight: "Interlineado: {{value}}",
margins: "Márgenes: {{value}}px",
brightness: "Brillo: {{value}}%",
orientation: "Orientación",
themeSepia: "Sepia",
themeDark: "Oscuro",
themeLight: "Claro",
themePaper: "Papel",
fontSans: "Sans-serif",
fontSerif: "Serif",
fontMonospace: "Monospace",
orientationAuto: "Auto",
orientationPortrait: "Vertical",
orientationLandscape: "Horizontal",
fontSizeAria: "Tamaño de fuente",
lineHeightAria: "Interlineado",
marginWidthAria: "Ancho de márgenes",
brightnessAria: "Brillo",
tocTitle: "Contenido",
closeTocAria: "Cerrar tabla de contenidos",
tocEmpty: "No hay capítulos disponibles.",
},
addBook: {
title: "Añadir libro",
fileLabel: "Archivo (EPUB o PDF) *",
selected: "Seleccionado: {{name}}",
titleLabel: "Título *",
titlePlaceholder: "Título del libro",
authorLabel: "Autor",
authorPlaceholder: "Nombre del autor",
uploading: "Subiendo...",
upload: "Subir libro",
fileTypeError: "Solo se admiten archivos EPUB y PDF.",
requiredError: "El título y el archivo son obligatorios.",
uploadFailed: "Error al subir.",
},
bookDetail: {
backToLibrary: "Volver a la biblioteca",
back: "Volver",
notFound: "Libro no encontrado",
notFoundDescription: "El libro que buscas no existe o fue eliminado.",
invalidId: "ID de libro no válido",
loadFailed: "Error al cargar los detalles del libro",
byAuthor: "de {{author}}",
pages: "{{count}} páginas",
description: "Descripción",
added: "Añadido {{date}}",
updated: "Actualizado {{date}}",
},
search: {
searching: "Buscando...",
noSuggestions: "Sin sugerencias rápidas",
},
annotations: {
title: "Marcadores y notas",
loading: "Cargando marcadores...",
empty: "Aún no hay marcadores ni notas.",
bookmarkCount: "{{count}} marcadores",
noteCount: "{{count}} notas",
markerCount: "{{count}} marcadores",
bookmark: "Marcador",
note: "Nota",
bookmarkOnly: "Marcador",
saveMarker: "Guardar",
markerSaved: "Marcador guardado",
markerSavedWithNote: "Nota guardada",
thoughtPlaceholder: "Tu reflexión sobre este pasaje (opcional)",
highlightColor: "Color del resaltado",
highlightCustom: "Color personalizado",
highlightYellow: "Amarillo",
highlightGreen: "Verde",
highlightPink: "Rosa",
highlightBlue: "Azul",
highlightPurple: "Morado",
selectTextHint: "Selecciona texto en el lector para añadir un marcador o nota.",
showMore: "Ver más",
showLess: "Ver menos",
goToPassage: "Ir al pasaje",
goToPage: "Ir a la página",
inBookPanel: "Marcadores de este libro",
chapterIndex: "Capítulo {{index}}",
page: "Página {{page}}",
pageShort: "p.{{page}}",
loadingBookmarks: "Cargando marcadores...",
bookmarksEmpty: "Aún no hay marcadores. Selecciona un pasaje mientras lees.",
loadingNotes: "Cargando notas...",
notesEmpty: "Aún no hay notas. Selecciona un pasaje mientras lees.",
addToPage: "Añadir a la página {{page}}",
addBookmark: "Añadir marcador",
addNote: "Añadir nota",
noteLabel: "Nota:",
notePlaceholder: "Escribe tu nota aquí...",
noteEmpty: "El contenido de la nota no puede estar vacío.",
saveFailed: "Error al guardar la anotación.",
close: "Cerrar",
home: "Inicio",
bookmarksNotes: "Marcadores y notas",
},
};
export default esES;
+35
View File
@@ -0,0 +1,35 @@
import enUS from "./en-US";
import esES from "./es-ES";
export type SupportedLanguage = "en-US" | "es-ES";
export const LOCALE_STORAGE_KEY = "cloud-reader.locale";
export const locales = {
"en-US": enUS,
"es-ES": esES,
};
export function resolveDefaultLanguage(): SupportedLanguage {
try {
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
if (saved === "en-US" || saved === "es-ES") return saved;
} catch {
// ignore storage errors
}
if (typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("es")) {
return "es-ES";
}
return "en-US";
}
export function readingStatusKey(status: string): string {
const map: Record<string, string> = {
want_to_read: "library.readingStatus.wantToRead",
reading: "library.readingStatus.reading",
finished: "library.readingStatus.finished",
dnf: "library.readingStatus.dnf",
uploaded: "library.readingStatus.uploaded",
};
return map[status] ?? "library.readingStatus.uploaded";
}
+2 -42
View File
@@ -1,48 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { Layout } from "@/components/layout";
import { BookmarksNotesPage } from "@/components/annotations/BookmarksNotesPage";
import App from "./App";
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(
@@ -50,4 +10,4 @@ if (rootElement) {
<App />
</React.StrictMode>
);
}
}
+17 -14
View File
@@ -1,8 +1,11 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books";
import { getApiErrorMessage } from "../api/errors";
export function AddBookPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [author, setAuthor] = useState("");
@@ -14,44 +17,44 @@ export function AddBookPage() {
const selected = e.target.files?.[0] ?? null;
if (selected) {
const ext = selected.name.split(".").pop()?.toLowerCase();
if (ext !== "epub" && ext !== "pdf") { setError("Only EPUB and PDF files are supported."); setFile(null); return; }
if (ext !== "epub" && ext !== "pdf") { setError(t("addBook.fileTypeError")); setFile(null); return; }
setFile(selected); setError(null);
if (!title) setTitle(selected.name.replace(/\.(epub|pdf)$/i, ""));
if (!title) setTitle(selected.name.replace(/\.(epub|pdf)$/i, "").slice(0, 512));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file || !title.trim()) { setError("Title and file are required."); return; }
if (!file || !title.trim()) { setError(t("addBook.requiredError")); return; }
setUploading(true); setError(null);
try { await booksApi.uploadEBook(file, title.trim(), author.trim()); navigate("/"); }
catch (err) { setError(err instanceof Error ? err.message : "Upload failed."); }
catch (err) { setError(getApiErrorMessage(err, t("addBook.uploadFailed"))); }
finally { setUploading(false); }
};
return (
<div style={{ maxWidth: 500, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<header style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24, padding: "16px 0", borderBottom: "1px solid #eee" }}>
<button onClick={() => navigate("/")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}> Back</button>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>Add Book</h1>
<button onClick={() => navigate("/")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}> {t("common.back")}</button>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{t("addBook.title")}</h1>
</header>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{error && <div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14 }}>{error}</div>}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>File (EPUB or PDF) *</label>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("addBook.fileLabel")}</label>
<input type="file" accept=".epub,.pdf" onChange={handleFileChange} style={{ padding: "10px 0" }} />
{file && <p style={{ fontSize: 13, color: "#666", marginTop: 4 }}>Selected: {file.name}</p>}
{file && <p style={{ fontSize: 13, color: "#666", marginTop: 4 }}>{t("addBook.selected", { name: file.name })}</p>}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Title *</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Book title" style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("addBook.titleLabel")}</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t("addBook.titlePlaceholder")} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Author</label>
<input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} placeholder="Author name" style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("addBook.authorLabel")}</label>
<input type="text" value={author} onChange={(e) => setAuthor(e.target.value)} placeholder={t("addBook.authorPlaceholder")} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>
<button type="submit" disabled={uploading} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: uploading ? 0.6 : 1, marginTop: 8 }}>{uploading ? "Uploading..." : "Upload Book"}</button>
<button type="submit" disabled={uploading} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: uploading ? 0.6 : 1, marginTop: 8 }}>{uploading ? t("addBook.uploading") : t("addBook.upload")}</button>
</form>
</div>
);
}
}
+17 -14
View File
@@ -1,8 +1,11 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { useAuth } from "../context/AuthContext";
import { getApiErrorMessage } from "../api/errors";
function AuthForm({ isLogin, onToggle }: { isLogin: boolean; onToggle: () => void }) {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -13,40 +16,40 @@ function AuthForm({ isLogin, onToggle }: { isLogin: boolean; onToggle: () => voi
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); setError(null);
if (!isLogin && password !== confirmPassword) { setError("Passwords do not match."); return; }
if (!isLogin && password !== confirmPassword) { setError(t("auth.passwordsMismatch")); return; }
setLoading(true);
try {
if (isLogin) await login(email, password);
else await register(email, password);
navigate("/");
} catch (err) { setError(err instanceof Error ? err.message : isLogin ? "Login failed" : "Registration failed"); }
} catch (err) { setError(getApiErrorMessage(err, isLogin ? t("auth.loginFailed") : t("auth.registrationFailed"))); }
finally { setLoading(false); }
};
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#f8f9fa", padding: 16 }}>
<div style={{ width: "100%", maxWidth: 400, background: "#fff", borderRadius: 12, padding: 32, boxShadow: "0 2px 16px rgba(0,0,0,0.08)" }}>
<h1 style={{ fontSize: 28, fontWeight: 700, color: "#1a1a2e", textAlign: "center", marginBottom: 4 }}>Cloud Reader</h1>
<h2 style={{ fontSize: 16, color: "#888", textAlign: "center", marginBottom: 24, fontWeight: 400 }}>{isLogin ? "Sign In" : "Create Account"}</h2>
<h1 style={{ fontSize: 28, fontWeight: 700, color: "#1a1a2e", textAlign: "center", marginBottom: 4 }}>{t("common.appName")}</h1>
<h2 style={{ fontSize: 16, color: "#888", textAlign: "center", marginBottom: 24, fontWeight: 400 }}>{isLogin ? t("auth.signIn") : t("auth.createAccount")}</h2>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{error && <div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14 }}>{error}</div>}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" required style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("auth.email")}</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t("auth.emailPlaceholder")} required style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="At least 8 characters" required minLength={8} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("auth.password")}</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("auth.passwordPlaceholder")} required minLength={8} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>
{!isLogin && <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Confirm Password</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="Repeat your password" required minLength={8} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("auth.confirmPassword")}</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder={t("auth.confirmPasswordPlaceholder")} required minLength={8} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, outline: "none" }} />
</div>}
<button type="submit" disabled={loading} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: loading ? 0.6 : 1, marginTop: 8 }}>{loading ? (isLogin ? "Signing in..." : "Creating account...") : (isLogin ? "Sign In" : "Create Account")}</button>
<button type="submit" disabled={loading} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: loading ? 0.6 : 1, marginTop: 8 }}>{loading ? (isLogin ? t("auth.signingIn") : t("auth.creatingAccount")) : (isLogin ? t("auth.signIn") : t("auth.createAccount"))}</button>
</form>
<p style={{ textAlign: "center", marginTop: 20, color: "#888", fontSize: 14 }}>
{isLogin ? "Don't have an account? " : "Already have an account? "}
<button onClick={onToggle} style={{ background: "none", border: "none", color: "#1a1a2e", fontWeight: 600, cursor: "pointer", fontSize: 14, textDecoration: "underline" }}>{isLogin ? "Register" : "Sign In"}</button>
{isLogin ? t("auth.noAccount") + " " : t("auth.hasAccount") + " "}
<button onClick={onToggle} style={{ background: "none", border: "none", color: "#1a1a2e", fontWeight: 600, cursor: "pointer", fontSize: 14, textDecoration: "underline" }}>{isLogin ? t("auth.register") : t("auth.signIn")}</button>
</p>
</div>
</div>
@@ -54,4 +57,4 @@ function AuthForm({ isLogin, onToggle }: { isLogin: boolean; onToggle: () => voi
}
export function LoginPage({ onToggle }: { onToggle: () => void }) { return <AuthForm isLogin={true} onToggle={onToggle} />; }
export function RegisterPage({ onToggle }: { onToggle: () => void }) { return <AuthForm isLogin={false} onToggle={onToggle} />; }
export function RegisterPage({ onToggle }: { onToggle: () => void }) { return <AuthForm isLogin={false} onToggle={onToggle} />; }
+18 -20
View File
@@ -1,10 +1,12 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books";
import type { BookDetail } from "../types/book";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
export function BookDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [book, setBook] = useState<BookDetail | null>(null);
@@ -19,17 +21,17 @@ export function BookDetailPage() {
try {
const bookId = Number(id);
if (Number.isNaN(bookId)) {
setError("Invalid book ID");
setError(t("bookDetail.invalidId"));
return;
}
const data = await booksApi.getBook(bookId);
setBook(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load book details");
setError(err instanceof Error ? err.message : t("bookDetail.loadFailed"));
} finally {
setLoading(false);
}
}, [id]);
}, [id, t]);
useEffect(() => {
void loadBook();
@@ -94,29 +96,27 @@ export function BookDetailPage() {
if (error || !book) {
return (
<div style={containerStyle}>
<button onClick={() => navigate("/")} style={backButtonStyle}> Back to Library</button>
<button onClick={() => navigate("/")} style={backButtonStyle}> {t("bookDetail.backToLibrary")}</button>
<div style={{ textAlign: "center", padding: isMobile ? "60px 16px" : "80px 20px" }}>
<div style={{ fontSize: isMobile ? 48 : 64, marginBottom: 16 }}>😕</div>
<h2 style={{ fontSize: isMobile ? 18 : 20, color: "#1f2937", marginBottom: 8 }}>Book not found</h2>
<p style={{ color: "#6b7280", marginBottom: 20 }}>{error || "The book you're looking for doesn't exist or has been removed."}</p>
<button onClick={() => void loadBook()} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>Retry</button>
<h2 style={{ fontSize: isMobile ? 18 : 20, color: "#1f2937", marginBottom: 8 }}>{t("bookDetail.notFound")}</h2>
<p style={{ color: "#6b7280", marginBottom: 20 }}>{error || t("bookDetail.notFoundDescription")}</p>
<button onClick={() => void loadBook()} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>{t("common.retry")}</button>
</div>
</div>
);
}
const sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" };
const dateFormat: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" };
return (
<div style={containerStyle}>
{/* Back button */}
<button onClick={() => navigate("/")} style={backButtonStyle}>
{isMobile ? "Back" : "Back to Library"}
{isMobile ? t("bookDetail.back") : t("bookDetail.backToLibrary")}
</button>
{/* Book Detail */}
<div style={{ display: "flex", gap: isMobile ? 20 : 32, flexDirection: isMobile ? "column" : "row" }}>
{/* Cover */}
<div style={{ flexShrink: 0, alignSelf: isMobile ? "center" : "flex-start" }}>
<div style={{
width: isMobile ? 160 : 240,
@@ -135,7 +135,6 @@ export function BookDetailPage() {
</div>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<h1 style={{ fontSize: isMobile ? 22 : 28, fontWeight: 700, color: "#1f2937", marginBottom: 8, lineHeight: 1.2 }}>
{book.title}
@@ -143,7 +142,7 @@ export function BookDetailPage() {
{book.author && (
<p style={{ fontSize: isMobile ? 16 : 18, color: "#4b5563", marginBottom: 6 }}>
by {book.author}
{t("bookDetail.byAuthor", { author: book.author })}
</p>
)}
@@ -158,14 +157,14 @@ export function BookDetailPage() {
)}
{book.total_pages > 0 && (
<span style={{ background: "#f3f4f6", color: "#6b7280", fontSize: 13, padding: "4px 12px", borderRadius: 999, minHeight: 28, display: "inline-flex", alignItems: "center" }}>
{book.total_pages} pages
{t("bookDetail.pages", { count: String(book.total_pages) })}
</span>
)}
</div>
{book.description && (
<div style={{ marginTop: 20 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: "#1f2937", marginBottom: 8 }}>Description</h3>
<h3 style={{ fontSize: 16, fontWeight: 600, color: "#1f2937", marginBottom: 8 }}>{t("bookDetail.description")}</h3>
<p style={{ fontSize: isMobile ? 15 : 15, color: "#4b5563", lineHeight: 1.7, whiteSpace: "pre-wrap" }}>
{book.description}
</p>
@@ -174,12 +173,11 @@ export function BookDetailPage() {
<div style={{ marginTop: 24, paddingTop: 16, borderTop: "1px solid #e5e7eb" }}>
<p style={{ fontSize: 13, color: "#9ca3af" }}>
Added {new Date(book.created_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}
{book.created_at !== book.updated_at && ` · Updated ${new Date(book.updated_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}`}
{t("bookDetail.added", { date: new Date(book.created_at).toLocaleDateString(undefined, dateFormat) })}
{book.created_at !== book.updated_at && ` · ${t("bookDetail.updated", { date: new Date(book.updated_at).toLocaleDateString(undefined, dateFormat) })}`}
</p>
</div>
{/* Mobile full-width back button */}
{isMobile && (
<div style={{ marginTop: 24 }}>
<button
@@ -197,7 +195,7 @@ export function BookDetailPage() {
minHeight: 44,
}}
>
Back to Library
{t("bookDetail.backToLibrary")}
</button>
</div>
)}
@@ -205,4 +203,4 @@ export function BookDetailPage() {
</div>
</div>
);
}
}
+16
View File
@@ -10,4 +10,20 @@
.bookCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.coverProgressBar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(0, 0, 0, 0.12);
z-index: 2;
}
.coverProgressFill {
height: 100%;
background: #16a34a;
transition: width 0.2s ease;
}
+199 -122
View File
@@ -1,14 +1,17 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books";
import type { BookListItem, BookSearchParams } from "../types/book";
import { READING_STATUS_OPTIONS } from "../types/book";
import { useAuth } from "../context/AuthContext";
import { deriveReadingStatus, normalizeProgressPercent } from "../utils/libraryStatus";
import { FinishedBooksShelf } from "../components/library/FinishedBooksShelf";
import { LibraryBookCard } from "../components/library/LibraryBookCard";
import { useDebounce } from "../hooks/useDebounce";
import { useVoiceSearch } from "../hooks/useVoiceSearch";
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
import { SearchSuggestions } from "../components/search/SearchSuggestions";
import styles from "./Library.module.css";
import { BookContextMenu } from "../components/BookContextMenu";
interface FilterState {
genre: string;
@@ -16,6 +19,17 @@ interface FilterState {
reading_status: string;
}
type LibraryBook = BookListItem & {
format: string;
progressPercent: number | null;
};
interface ContextMenuState {
book: LibraryBook;
x: number;
y: number;
}
/** WCAG 2.1 minimum touch target */
const TOUCH_TARGET: React.CSSProperties = {
minHeight: 44,
@@ -28,10 +42,12 @@ const TOUCH_TARGET: React.CSSProperties = {
export function LibraryPage() {
const navigate = useNavigate();
const { logout } = useAuth();
const { t } = useTranslation();
const [books, setBooks] = useState<BookListItem[]>([]);
const [books, setBooks] = useState<LibraryBook[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [readerNotice, setReaderNotice] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<FilterState>({ genre: "", author: "", reading_status: "" });
const [genres, setGenres] = useState<string[]>([]);
@@ -39,6 +55,7 @@ export function LibraryPage() {
const [totalCount, setTotalCount] = useState(0);
const [showFilters, setShowFilters] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const debouncedSearch = useDebounce(searchQuery, 300);
const loadedRef = useRef(false);
@@ -75,17 +92,54 @@ export function LibraryPage() {
setLoading(true);
setError(null);
try {
const response = await booksApi.searchBooks(params);
setBooks(response.results);
setTotalCount(response.count);
const ebooks = await booksApi.getEBooks();
let items: LibraryBook[] = ebooks.map((e) => {
const reading_status = deriveReadingStatus(e.progress, Boolean(e.started));
const progressPercent = normalizeProgressPercent(e.progress, reading_status);
return {
id: e.id,
title: e.title,
author: e.author,
genre: e.format ? e.format.toUpperCase() : "",
format: e.format,
reading_status,
reading_status_display: reading_status,
cover_image: e.cover_image,
progressPercent,
};
});
const query = params.q?.trim().toLowerCase();
if (query) {
items = items.filter(
(b) =>
b.title.toLowerCase().includes(query) ||
b.author.toLowerCase().includes(query) ||
b.genre.toLowerCase().includes(query),
);
}
if (params.author) items = items.filter((b) => b.author === params.author);
if (params.genre) items = items.filter((b) => b.genre === params.genre);
if (params.reading_status) {
items = items.filter((b) => b.reading_status === params.reading_status);
}
setBooks(items);
setTotalCount(items.length);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load books");
setError(err instanceof Error ? err.message : t("library.loadFailed"));
setBooks([]);
setTotalCount(0);
} finally {
setLoading(false);
}
}, []);
}, [t]);
const statusFilterOptions = [
{ value: "", label: t("library.allStatuses") },
{ value: "want_to_read", label: t("library.readingStatus.wantToRead") },
{ value: "reading", label: t("library.readingStatus.reading") },
{ value: "finished", label: t("library.readingStatus.finished") },
{ value: "dnf", label: t("library.readingStatus.dnf") },
];
// Reload when search or filters change
useEffect(() => {
@@ -109,10 +163,59 @@ export function LibraryPage() {
const hasActiveFilters = !!searchQuery || !!filters.genre || !!filters.author || !!filters.reading_status;
const handleBookUpdated = useCallback((updated: LibraryBook) => {
setBooks((prev) =>
prev.map((b) =>
b.id === updated.id
? {
...b,
...updated,
progressPercent: updated.progressPercent ?? b.progressPercent,
reading_status: updated.reading_status ?? b.reading_status,
}
: b,
),
);
}, []);
const handleBookRemoved = useCallback((id: number) => {
setBooks((prev) => prev.filter((b) => b.id !== id));
setTotalCount((count) => Math.max(0, count - 1));
}, []);
const handleBookContextMenu = useCallback((e: React.MouseEvent, book: LibraryBook) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ book, x: e.clientX, y: e.clientY });
}, []);
const activeBooks = useMemo(
() => books.filter((b) => b.reading_status !== "finished"),
[books],
);
const finishedBooks = useMemo(
() => books.filter((b) => b.reading_status === "finished"),
[books],
);
const shelfVisible = finishedBooks.length > 0;
const finishedFilterActive = filters.reading_status === "finished";
const openBook = useCallback(
(book: LibraryBook) => {
if (book.format === "pdf") {
setReaderNotice(t("library.pdfNotice"));
return;
}
navigate(`/read/${book.id}`);
},
[navigate, t],
);
const containerStyle: React.CSSProperties = {
maxWidth: 960,
margin: "0 auto",
padding: isMobile ? 12 : 16,
paddingBottom: shelfVisible ? (isMobile ? 72 : 68) : undefined,
minHeight: "100vh",
background: "#f8f9fa",
};
@@ -138,27 +241,35 @@ export function LibraryPage() {
{/* Header */}
<header style={headerStyle}>
<div>
<h1 style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>Library</h1>
<h1 style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>{t("library.title")}</h1>
{!loading && (
<p style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>
{totalCount} book{totalCount !== 1 ? "s" : ""}
{activeBooks.length === 1
? t("library.bookCountOne", { count: String(activeBooks.length) })
: t("library.bookCountMany", { count: String(activeBooks.length) })}
{finishedBooks.length > 0 && (
<span>
{" · "}
{t("library.finishedCountShort", { count: String(finishedBooks.length) })}
</span>
)}
</p>
)}
</div>
<div style={{ display: "flex", gap: isMobile ? 4 : 8, flexWrap: "wrap", alignItems: "center" }}>
{isMobile ? (
<>
<button onClick={() => navigate("/add")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Add Book"></button>
<button onClick={() => navigate("/bookmarks-notes")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Bookmarks">🔖</button>
<button onClick={() => navigate("/settings")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Settings"></button>
<button onClick={logout} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Logout">🚪</button>
<button onClick={() => navigate("/add")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.addBook")}></button>
<button onClick={() => navigate("/bookmarks-notes")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.bookmarks")}>🔖</button>
<button onClick={() => navigate("/settings")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.settings")}></button>
<button onClick={logout} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.logout")}>🚪</button>
</>
) : (
<>
<button onClick={() => navigate("/add")} className="btn">+ Add Book</button>
<button onClick={() => navigate("/bookmarks-notes")} className="btn btn-secondary">Bookmarks</button>
<button onClick={() => navigate("/settings")} className="btn btn-secondary">Settings</button>
<button onClick={logout} className="btn btn-danger">Logout</button>
<button onClick={() => navigate("/add")} className="btn">+ {t("library.addBook")}</button>
<button onClick={() => navigate("/bookmarks-notes")} className="btn btn-secondary">{t("library.bookmarks")}</button>
<button onClick={() => navigate("/settings")} className="btn btn-secondary">{t("library.settings")}</button>
<button onClick={logout} className="btn btn-danger">{t("library.logout")}</button>
</>
)}
</div>
@@ -171,7 +282,7 @@ export function LibraryPage() {
<input
ref={searchInputRef}
type="text"
placeholder="Search by title, author, or genre..."
placeholder={t("library.searchPlaceholder")}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
@@ -225,7 +336,7 @@ export function LibraryPage() {
color: voiceSearch.isListening ? "#fff" : "#6b7280",
transition: "background 0.15s",
}}
title={voiceSearch.isListening ? "Stop listening" : "Search with voice"}
title={voiceSearch.isListening ? t("library.voiceSearchStopTitle") : t("library.voiceSearchTitle")}
>
🎤
</button>
@@ -255,7 +366,7 @@ export function LibraryPage() {
gap: 4,
}}
>
{isMobile ? "⚙️" : showFilters ? "▲ Filters" : "▼ Filters"}
{isMobile ? "⚙️" : `${t("library.filtersLabel")}`}
</button>
</div>
</div>
@@ -274,7 +385,7 @@ export function LibraryPage() {
color: "#dc2626",
}}>
<span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: "#dc2626", animation: "pulse 1s infinite" }} />
Listening... speak now
{t("library.listening")}
<button
onClick={voiceSearch.stopListening}
style={{
@@ -288,7 +399,7 @@ export function LibraryPage() {
fontSize: 12,
}}
>
Stop
{t("library.stop")}
</button>
</div>
)}
@@ -303,7 +414,7 @@ export function LibraryPage() {
fontSize: 13,
color: "#92400e",
}}>
Voice search: {voiceSearch.errorMessage === "no-speech" ? "No speech detected. Try again." : voiceSearch.errorMessage}
{voiceSearch.errorMessage === "no-speech" ? t("library.voiceNoSpeech") : t("library.voiceError", { message: voiceSearch.errorMessage ?? "" })}
</div>
)}
@@ -321,7 +432,7 @@ export function LibraryPage() {
alignItems: isMobile ? "stretch" : "end",
}}>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Genre</label>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>{t("library.genre")}</label>
<select
value={filters.genre}
onChange={(e) => handleFilterChange("genre", e.target.value)}
@@ -336,12 +447,12 @@ export function LibraryPage() {
minHeight: 36,
}}
>
<option value="">All Genres</option>
<option value="">{t("library.allGenres")}</option>
{genres.map((g) => <option key={g} value={g}>{g}</option>)}
</select>
</div>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Author</label>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>{t("library.author")}</label>
<select
value={filters.author}
onChange={(e) => handleFilterChange("author", e.target.value)}
@@ -356,12 +467,12 @@ export function LibraryPage() {
minHeight: 36,
}}
>
<option value="">All Authors</option>
<option value="">{t("library.allAuthors")}</option>
{authors.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>Status</label>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>{t("library.status")}</label>
<select
value={filters.reading_status}
onChange={(e) => handleFilterChange("reading_status", e.target.value)}
@@ -376,7 +487,7 @@ export function LibraryPage() {
minHeight: 36,
}}
>
{READING_STATUS_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
{statusFilterOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
{hasActiveFilters && (
@@ -395,7 +506,7 @@ export function LibraryPage() {
width: isMobile ? "100%" : "auto",
}}
>
Clear
{t("library.clearFilters")}
</button>
)}
</div>
@@ -405,7 +516,14 @@ export function LibraryPage() {
{error && (
<div style={{ background: "#fef2f2", padding: 16, borderRadius: 8, marginBottom: 16, textAlign: "center" }}>
<p style={{ color: "#dc2626", fontSize: 14 }}>{error}</p>
<button onClick={() => void loadBooks({})} style={{ marginTop: 8, padding: "8px 16px", border: "none", borderRadius: 6, background: "#dc2626", color: "#fff", cursor: "pointer", fontSize: 13, minHeight: 36 }}>Retry</button>
<button onClick={() => void loadBooks({})} style={{ marginTop: 8, padding: "8px 16px", border: "none", borderRadius: 6, background: "#dc2626", color: "#fff", cursor: "pointer", fontSize: 13, minHeight: 36 }}>{t("common.retry")}</button>
</div>
)}
{readerNotice && (
<div style={{ background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 8, padding: "12px 16px", marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
<p style={{ color: "#92400e", fontSize: 14, margin: 0 }}>{readerNotice}</p>
<button type="button" onClick={() => setReaderNotice(null)} style={{ padding: "4px 10px", border: "none", borderRadius: 6, background: "#f59e0b", color: "#fff", cursor: "pointer", fontSize: 12, flexShrink: 0 }}>{t("common.dismiss")}</button>
</div>
)}
@@ -434,112 +552,71 @@ export function LibraryPage() {
<div style={{ textAlign: "center", padding: isMobile ? "60px 16px" : "80px 20px" }}>
<div style={{ fontSize: isMobile ? 48 : 64, marginBottom: 16 }}>{hasActiveFilters ? "🔍" : "📚"}</div>
<h2 style={{ fontSize: isMobile ? 18 : 20, color: "#1f2937", marginBottom: 8 }}>
{hasActiveFilters ? "No books found" : "Your library is empty"}
{hasActiveFilters ? t("library.emptyFilteredTitle") : t("library.emptyTitle")}
</h2>
<p style={{ color: "#6b7280", marginBottom: 20, fontSize: 15, lineHeight: 1.5 }}>
{hasActiveFilters
? "Try adjusting your search query or filters to discover more books."
: "Add a book to get started building your collection."}
? t("library.emptyFilteredDescription")
: t("library.emptyDescription")}
</p>
{hasActiveFilters ? (
<button onClick={clearAllFilters} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>
Clear All Filters
{t("library.clearAllFilters")}
</button>
) : (
<button onClick={() => navigate("/add")} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>
Add Your First Book
{t("library.addFirstBook")}
</button>
)}
</div>
)}
{/* Results Grid */}
{!loading && books.length > 0 && (
<div style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(200px, 1fr))",
gap: isMobile ? 12 : 16,
}}>
{books.map((book) => {
const statusColors: Record<string, { bg: string; text: string }> = {
want_to_read: { bg: "#dbeafe", text: "#1d4ed8" },
reading: { bg: "#dcfce7", text: "#16a34a" },
finished: { bg: "#f3e8ff", text: "#9333ea" },
dnf: { bg: "#fef3c7", text: "#b45309" },
};
const sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" };
{!loading && activeBooks.length === 0 && finishedBooks.length > 0 && !hasActiveFilters && (
<p style={{ textAlign: "center", color: "#6b7280", fontSize: 14, marginBottom: 16 }}>
{t("library.allInFinishedShelf")}
</p>
)}
return (
<div
key={book.id}
onClick={() => navigate(`/books/${book.id}`)}
className={styles.bookCard}
style={{
display: isMobile ? "flex" : "block",
}}
>
<div style={{
width: isMobile ? 80 : "100%",
height: isMobile ? 120 : 180,
background: "#f0f0f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}>
{book.cover_image
? <img src={book.cover_image} alt={book.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: <span style={{ fontSize: isMobile ? 32 : 48 }}>📖</span>}
{!isMobile && (
<span style={{
position: "absolute", top: 8, right: 8,
background: sc.bg, color: sc.text, fontSize: 11, fontWeight: 600,
padding: "2px 8px", borderRadius: 999, lineHeight: "18px",
}}>
{book.reading_status_display}
</span>
)}
</div>
<div style={{ padding: isMobile ? "8px 12px" : 12, flex: 1 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<h3 style={{
fontSize: isMobile ? 14 : 14,
fontWeight: 600,
color: "#1f2937",
marginBottom: 2,
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{book.title}
</h3>
<p style={{ fontSize: 12, color: "#6b7280", marginBottom: 4, margin: "2px 0" }}>
{book.author || "Unknown Author"}
</p>
</div>
{isMobile && (
<span style={{
background: sc.bg, color: sc.text, fontSize: 10, fontWeight: 600,
padding: "2px 6px", borderRadius: 999, whiteSpace: "nowrap", flexShrink: 0,
}}>
{book.reading_status_display}
</span>
)}
</div>
{book.genre && (
<span style={{ fontSize: 11, color: "#4f46e5", background: "#eef2ff", padding: "1px 6px", borderRadius: 4, display: "inline-block", marginTop: 4 }}>
{book.genre}
</span>
)}
</div>
</div>
);
})}
{/* Active library grid (want to read, reading) */}
{!loading && activeBooks.length > 0 && (
<div
style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(200px, 1fr))",
gap: isMobile ? 12 : 16,
}}
>
{activeBooks.map((book) => (
<LibraryBookCard
key={book.id}
book={book}
isMobile={isMobile}
onOpen={() => openBook(book)}
onContextMenu={(e) => handleBookContextMenu(e, book)}
/>
))}
</div>
)}
{shelfVisible && (
<FinishedBooksShelf
books={finishedBooks}
defaultExpanded={finishedFilterActive}
onOpenBook={(book) => openBook(book as LibraryBook)}
onContextMenu={(e, book) => handleBookContextMenu(e, book as LibraryBook)}
/>
)}
{contextMenu && (
<BookContextMenu
book={contextMenu.book}
x={contextMenu.x}
y={contextMenu.y}
onClose={() => setContextMenu(null)}
onBookUpdated={handleBookUpdated}
onBookRemoved={handleBookRemoved}
/>
)}
</div>
);
}
-64
View File
@@ -1,64 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { booksApi } from "../api/books";
import type { EBookDetail } from "../types/book";
export function ReaderPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [book, setBook] = useState<EBookDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const progressTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const bookId = Number(id);
const saveProgress = useCallback(async () => {
if (!scrollRef.current || !book) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const position = Math.min(100, Math.round((scrollTop / (scrollHeight - clientHeight)) * 100));
try { await booksApi.updateProgress(bookId, { current_position: position }); } catch { /* silent */ }
}, [bookId, book]);
useEffect(() => {
const loadBook = async () => {
setLoading(true); setError(null);
try {
const data = await booksApi.getEBook(bookId);
setBook(data);
document.title = data.title;
} catch (err) { setError(err instanceof Error ? err.message : "Failed to load book"); }
finally { setLoading(false); }
};
void loadBook();
return () => { document.title = "Cloud Reader"; };
}, [bookId]);
useEffect(() => {
progressTimer.current = setInterval(() => { void saveProgress(); }, 5000);
return () => { if (progressTimer.current) clearInterval(progressTimer.current); };
}, [saveProgress]);
useEffect(() => {
const handleBeforeUnload = () => { void saveProgress(); };
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [saveProgress]);
if (loading) return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", height: "100vh", color: "#888" }}><p>Loading book...</p></div>;
if (error) return <div style={{ display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", height: "100vh", gap: 16 }}><p style={{ color: "#e74c3c", fontSize: 16 }}>{error}</p><button onClick={() => navigate("/")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer" }}> Back to Library</button></div>;
if (!book) return null;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: "#fff" }}>
<header style={{ display: "flex", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid #eee", gap: 12, flexShrink: 0 }}>
<button onClick={() => navigate("/")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}> Library</button>
<div style={{ flex: 1 }}><h1 style={{ fontSize: 18, fontWeight: 600, color: "#1a1a2e", margin: 0 }}>{book.title}</h1><p style={{ fontSize: 14, color: "#888", margin: "4px 0 0" }}>{book.author}</p></div>
<button onClick={() => navigate(`/bookmarks-notes/${bookId}`)} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}>📑 Bookmarks & Notes</button>
</header>
<div ref={scrollRef} style={{ flex: 1, overflow: "auto", background: "#fafafa" }}>
{book.file_url ? <iframe src={book.file_url} style={{ width: "100%", height: "100%", border: "none" }} title={book.title} /> : <div style={{ display: "flex", justifyContent: "center", alignItems: "center", height: "100%", color: "#888" }}><p>No file available.</p></div>}
</div>
</div>
);
}
+173 -139
View File
@@ -1,121 +1,159 @@
/**
* ReadingPage full-screen reading view for ebooks.
* Integrates TOC drawer, settings panel, chapter navigation, and progress tracking.
* Sanitizes HTML content with DOMPurify to prevent XSS.
* ReadingPage full-screen EPUB reading view powered by react-reader (epub.js).
*/
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import DOMPurify from "dompurify";
import { useChapters } from "../hooks/useChapters";
import { useReadingProgress } from "../hooks/useReadingProgress";
import { useReadingSettings } from "../hooks/useReadingSettings";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { EpubView, EpubViewStyle } from "react-reader";
import { booksApi } from "../api/books";
import type { BookDetail } from "../types/book";
import { useEpubHighlights } from "../hooks/useEpubHighlights";
import { useEpubReader } from "../hooks/useEpubReader";
import { useEpubSelection } from "../hooks/useEpubSelection";
import { useReadingSettings } from "../hooks/useReadingSettings";
import type { EBookDetail } from "../types/book";
import type { EpubTocItem } from "../components/reader/TableOfContents";
import { SelectionPopover } from "../components/reader/SelectionPopover";
import { BookMarkersPanel } from "../components/reader/BookMarkersPanel";
import { ResumeReadingButton } from "../components/reader/ResumeReadingButton";
import type { MarkerEntry } from "@/types";
import "../reader.css";
const ReaderToolbar = lazy(() => import("../components/reader/ReaderToolbar"));
const TableOfContents = lazy(() => import("../components/reader/TableOfContents"));
const ReadingSettingsPanel = lazy(
() => import("../components/reader/ReadingSettingsPanel")
() => import("../components/reader/ReadingSettingsPanel"),
);
type LocationState = { epubLocation?: string } | null;
export default function ReadingPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const routerLocation = useLocation();
const bookId = Number(id);
const [book, setBook] = useState<BookDetail | null>(null);
const initialEpubLocation = (routerLocation.state as LocationState)?.epubLocation;
const [book, setBook] = useState<EBookDetail | null>(null);
const [bookLoading, setBookLoading] = useState(true);
const [bookError, setBookError] = useState<string | null>(null);
const [tocOpen, setTocOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [markersOpen, setMarkersOpen] = useState(false);
const [tocItems, setTocItems] = useState<EpubTocItem[]>([]);
const [renditionVersion, setRenditionVersion] = useState(0);
const { settings, updateSettings, flushSettings } = useReadingSettings();
const {
epubUrl,
location,
chapterTitle,
progress,
isLoading: epubLoading,
error: epubError,
handleLocationChanged,
handleGetRendition,
navigateToHref,
beginBookmarkPeek,
resumeReadingAnchor,
isBookmarkPeekActive,
readingAnchor,
goToNextPage,
goToPrevPage,
applySettings,
epubOptions,
renditionRef,
} = useEpubReader(bookId, settings.margin_width, initialEpubLocation);
const { pendingSelection, clearSelection } = useEpubSelection(
renditionRef,
chapterTitle,
renditionVersion,
);
useEpubHighlights(bookId, renditionRef, renditionVersion);
// Fetch book details
useEffect(() => {
if (!bookId || Number.isNaN(bookId)) return;
setBookLoading(true);
booksApi.getBook(bookId)
.then(setBook)
.catch(() => { /* handled by useChapters error */ })
setBookError(null);
booksApi
.getEBook(bookId)
.then((data) => {
if (data.format !== "epub") {
setBookError(t("reader.epubOnly"));
setBook(null);
return;
}
setBook(data);
})
.catch(() => setBookError(t("reader.loadBookFailed")))
.finally(() => setBookLoading(false));
}, [bookId]);
const [tocOpen, setTocOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const { settings, updateSettings } = useReadingSettings();
const {
chapters,
currentChapter,
currentChapterNumber,
isLoading,
error,
navigateToChapter,
goToNextChapter,
goToPreviousChapter,
hasNext,
hasPrevious,
} = useChapters(bookId, 1);
const { progress, debouncedSave } = useReadingProgress(bookId);
// Auto-save progress when chapter changes
useEffect(() => {
if (currentChapter && currentChapterNumber > 0) {
debouncedSave(currentChapterNumber, 0, (currentChapterNumber / Math.max(chapters.length, 1)) * 100);
}
}, [currentChapterNumber, currentChapter?.id]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowLeft" && hasPrevious) {
goToPreviousChapter();
} else if (e.key === "ArrowRight" && hasNext) {
goToNextChapter();
} else if (e.key === "Escape") {
setTocOpen(false);
setSettingsOpen(false);
}
},
[hasNext, hasPrevious, goToNextChapter, goToPreviousChapter]
);
}, [bookId, t]);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
applySettings(settings);
}, [settings, applySettings]);
// Lock/unlock orientation via CSS
useEffect(() => {
const root = document.documentElement;
if (settings.orientation_lock !== "auto") {
root.style.setProperty(
"--reader-orientation",
settings.orientation_lock === "portrait" ? "portrait" : "landscape"
settings.orientation_lock === "portrait" ? "portrait" : "landscape",
);
} else {
root.style.removeProperty("--reader-orientation");
}
}, [settings.orientation_lock]);
// Sanitize chapter content to prevent XSS
const sanitizedContent = currentChapter?.content
? DOMPurify.sanitize(currentChapter.content)
: "";
const handleTocChanged = useCallback((toc: EpubTocItem[]) => {
setTocItems(toc);
}, []);
// Show loading state
if (bookLoading || (isLoading && !currentChapter)) {
const handleTocNavigate = useCallback(
(href: string) => {
navigateToHref(href);
},
[navigateToHref],
);
const onGetRendition = useCallback(
(rendition: Parameters<typeof handleGetRendition>[0]) => {
handleGetRendition(rendition);
setRenditionVersion((v) => v + 1);
},
[handleGetRendition],
);
const handleGoToMarker = useCallback(
(marker: MarkerEntry) => {
beginBookmarkPeek(marker.epub_cfi);
setMarkersOpen(false);
},
[beginBookmarkPeek],
);
const isLoading = bookLoading || epubLoading;
const error = bookError ?? epubError;
if (isLoading) {
return (
<div className="reader-loading">
<div className="spinner" />
<p>Loading reader...</p>
<p>{t("reader.loading")}</p>
</div>
);
}
if (error) {
if (error || !epubUrl) {
return (
<div className="reader-loading">
<p className="reader-error">{error}</p>
<button type="button" className="back-button" onClick={() => navigate(`/books/${bookId}`)}>
Back to book
<p className="reader-error">{error ?? t("reader.unableToOpen")}</p>
<button type="button" className="back-button" onClick={() => navigate("/")}>
{t("reader.backToLibrary")}
</button>
</div>
);
@@ -129,21 +167,29 @@ export default function ReadingPage() {
</div>
}
>
<div className="reader-container">
<div className="reader-container" data-theme={settings.theme}>
<ReaderToolbar
bookTitle={book?.title ?? "Loading..."}
chapterTitle={currentChapter?.title ?? "Loading..."}
bookTitle={book?.title ?? t("reader.reading")}
chapterTitle={chapterTitle || t("reader.reading")}
progress={progress}
onBack={() => navigate("/")}
onToggleToc={() => setTocOpen((v) => !v)}
onToggleSettings={() => setSettingsOpen((v) => !v)}
onToggleMarkers={() => setMarkersOpen((v) => !v)}
/>
<TableOfContents
chapters={chapters}
currentChapterNumber={currentChapterNumber}
items={tocItems}
isOpen={tocOpen}
onClose={() => setTocOpen(false)}
onNavigate={navigateToChapter}
onNavigate={handleTocNavigate}
/>
<BookMarkersPanel
ebookId={bookId}
isOpen={markersOpen}
onClose={() => setMarkersOpen(false)}
onGoToPassage={handleGoToMarker}
/>
<ReadingSettingsPanel
@@ -151,70 +197,58 @@ export default function ReadingPage() {
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
onUpdate={updateSettings}
onFlush={flushSettings}
/>
{/* Main reading area */}
<main className="reader-content">
{isLoading ? (
<div className="reader-loading">
<div className="spinner" />
<p>Loading chapter...</p>
</div>
) : currentChapter ? (
<article className="reader-chapter">
<h1 className="reader-chapter-title">{currentChapter.title}</h1>
<div
className="reader-chapter-body"
dangerouslySetInnerHTML={{
__html: sanitizedContent,
}}
/>
</article>
) : (
<div className="reader-loading">
<p>Select a chapter from the table of contents to start reading.</p>
</div>
)}
{pendingSelection && (
<SelectionPopover
ebookId={bookId}
selection={pendingSelection}
onClose={clearSelection}
onSaved={clearSelection}
/>
)}
<main className="reader-epub-container">
<EpubView
url={epubUrl}
location={location}
locationChanged={handleLocationChanged}
tocChanged={handleTocChanged}
getRendition={(rendition) =>
onGetRendition(rendition as unknown as Parameters<typeof handleGetRendition>[0])
}
epubInitOptions={{ openAs: "epub" }}
epubOptions={epubOptions}
epubViewStyles={EpubViewStyle}
loadingView={
<div className="reader-loading">
<div className="spinner" />
</div>
}
/>
<button
type="button"
className="reader-page-nav reader-page-nav--prev"
onClick={goToPrevPage}
aria-label={t("reader.prevPage")}
>
</button>
<ResumeReadingButton
visible={isBookmarkPeekActive && readingAnchor != null}
onResume={resumeReadingAnchor}
/>
<button
type="button"
className="reader-page-nav reader-page-nav--next"
onClick={goToNextPage}
aria-label={t("reader.nextPage")}
>
</button>
</main>
{/* Bottom navigation bar */}
<nav className="reader-nav">
<button
type="button"
className={`reader-nav-btn ${!hasPrevious ? "reader-nav-btn--disabled" : ""}`}
onClick={goToPreviousChapter}
disabled={!hasPrevious}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Previous
</button>
<button
type="button"
className="reader-nav-btn reader-nav-btn--back"
onClick={() => navigate("/")}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12H4M10 18l-6-6 6-6" />
</svg>
Library
</button>
<button
type="button"
className={`reader-nav-btn ${!hasNext ? "reader-nav-btn--disabled" : ""}`}
onClick={goToNextChapter}
disabled={!hasNext}
>
Next
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</nav>
</div>
</Suspense>
);
}
}
+34 -19
View File
@@ -1,16 +1,19 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18n-lite";
import { booksApi } from "../api/books";
import type { ReadingSettings } from "../types/book";
import type { SupportedLanguage } from "../locales";
const BG_COLORS = [
{ value: "#ffffff", label: "White" },
{ value: "#f4e4c1", label: "Sepia" },
{ value: "#1a1a2e", label: "Dark" },
{ value: "#c7edcc", label: "Green" },
{ value: "#ffffff", labelKey: "settings.bgWhite" },
{ value: "#f4e4c1", labelKey: "settings.bgSepia" },
{ value: "#1a1a2e", labelKey: "settings.bgDark" },
{ value: "#c7edcc", labelKey: "settings.bgGreen" },
];
export function SettingsPage() {
const { t, language, setLanguage } = useTranslation();
const navigate = useNavigate();
const [settings, setSettings] = useState<ReadingSettings | null>(null);
const [loading, setLoading] = useState(true);
@@ -21,51 +24,63 @@ export function SettingsPage() {
useEffect(() => {
const load = async () => {
try { const data = await booksApi.getSettings(); setSettings(data); }
catch (err) { setError(err instanceof Error ? err.message : "Failed to load settings"); }
catch (err) { setError(err instanceof Error ? err.message : t("settings.loadFailed")); }
finally { setLoading(false); }
};
void load();
}, []);
}, [t]);
const handleSave = async () => {
if (!settings) return;
setSaving(true); setError(null); setSuccess(false);
try { await booksApi.updateSettings(settings); setSuccess(true); setTimeout(() => setSuccess(false), 2000); }
catch (err) { setError(err instanceof Error ? err.message : "Failed to save settings"); }
catch (err) { setError(err instanceof Error ? err.message : t("settings.saveFailed")); }
finally { setSaving(false); }
};
if (loading) return <div style={{ maxWidth: 500, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}><p>Loading settings...</p></div>;
if (loading) return <div style={{ maxWidth: 500, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}><p>{t("settings.loading")}</p></div>;
return (
<div style={{ maxWidth: 500, margin: "0 auto", padding: 16, minHeight: "100vh", background: "#f8f9fa" }}>
<header style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24, padding: "16px 0", borderBottom: "1px solid #eee" }}>
<button onClick={() => navigate("/")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}> Back</button>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>Reading Settings</h1>
<button onClick={() => navigate("/")} style={{ padding: "8px 16px", borderRadius: 6, border: "1px solid #ddd", background: "#fff", cursor: "pointer", fontSize: 14 }}> {t("common.back")}</button>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#1a1a2e", margin: 0 }}>{t("settings.title")}</h1>
</header>
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
{error && <div style={{ background: "#fde8e8", padding: 12, borderRadius: 6, color: "#e74c3c", fontSize: 14 }}>{error}</div>}
{success && <div style={{ background: "#d4edda", padding: 12, borderRadius: 6, color: "#155724", fontSize: 14 }}>Settings saved!</div>}
{success && <div style={{ background: "#d4edda", padding: 12, borderRadius: 6, color: "#155724", fontSize: 14 }}>{t("settings.saved")}</div>}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("settings.uiLanguage")}</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value as SupportedLanguage)}
style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, background: "#fff", outline: "none" }}
>
<option value="en-US">{t("settings.uiLanguageEn")}</option>
<option value="es-ES">{t("settings.uiLanguageEs")}</option>
</select>
</div>
{settings && <>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Font Size ({settings.font_size}px)</label>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("settings.fontSize", { size: String(settings.font_size) })}</label>
<input type="range" min={12} max={36} value={settings.font_size} onChange={(e) => setSettings({ ...settings, font_size: Number(e.target.value) })} style={{ width: "100%", cursor: "pointer" }} />
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: "#999" }}><span>12px</span><span>36px</span></div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Font Style</label>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("settings.fontStyle")}</label>
<select value={settings.font_style} onChange={(e) => setSettings({ ...settings, font_style: e.target.value as ReadingSettings["font_style"] })} style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, background: "#fff", outline: "none" }}>
<option value="sans-serif">Sans Serif</option><option value="serif">Serif</option><option value="monospace">Monospace</option>
<option value="sans-serif">{t("settings.fontSans")}</option><option value="serif">{t("settings.fontSerif")}</option><option value="monospace">{t("settings.fontMonospace")}</option>
</select>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Background Color</label>
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>{t("settings.backgroundColor")}</label>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{BG_COLORS.map((bg) => (
<button key={bg.value} onClick={() => setSettings({ ...settings, background_color: bg.value })}
style={{ width: 48, height: 48, borderRadius: "50%", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", background: bg.value, border: settings.background_color === bg.value ? "3px solid #1a1a2e" : "3px solid #ddd" }}
title={bg.label}>
title={t(bg.labelKey)}>
{settings.background_color === bg.value && <span style={{ fontSize: 20, fontWeight: 700, color: bg.value === "#ffffff" || bg.value === "#f4e4c1" ? "#1a1a2e" : "#fff" }}></span>}
</button>
))}
@@ -73,8 +88,8 @@ export function SettingsPage() {
</div>
</>}
<button onClick={handleSave} disabled={saving} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: saving ? 0.6 : 1, marginTop: 8 }}>{saving ? "Saving..." : "Save Settings"}</button>
<button onClick={handleSave} disabled={saving} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: saving ? 0.6 : 1, marginTop: 8 }}>{saving ? t("common.saving") : t("settings.saveSettings")}</button>
</div>
</div>
);
}
}
+365 -212
View File
@@ -7,9 +7,8 @@
:root {
--reader-bg: #f5f0eb;
--reader-text: #1a1a1a;
--reader-font-family: Georgia, "Times New Roman", serif;
--reader-font-size: 18px;
--reader-line-height: 1.6;
--reader-ui-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
--reader-margin: 16px;
--reader-brightness: 100%;
--reader-toolbar-bg: rgba(26, 26, 26, 0.95);
@@ -26,9 +25,7 @@
bottom: 0;
background: var(--reader-bg);
color: var(--reader-text);
font-family: var(--reader-font-family);
font-size: var(--reader-font-size);
line-height: var(--reader-line-height);
font-family: var(--reader-ui-font);
filter: brightness(var(--reader-brightness));
display: flex;
flex-direction: column;
@@ -102,7 +99,7 @@
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: var(--reader-ui-font);
}
.reader-bar-chapter {
@@ -112,7 +109,13 @@
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: var(--reader-ui-font);
}
.reader-bar-actions {
display: flex;
align-items: center;
gap: 4px;
}
/* --- Progress Bar --- */
@@ -132,132 +135,149 @@
transition: width 0.3s ease;
}
/* --- Main Content Area --- */
.reader-content {
flex: 1;
overflow-y: auto;
padding: 72px var(--reader-margin) 72px;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
/* --- Chapter Article --- */
.reader-chapter {
max-width: 680px;
margin: 0 auto;
}
.reader-chapter-title {
font-size: 1.6em;
font-weight: 700;
line-height: 1.3;
margin-bottom: 0.75em;
padding-bottom: 0.5em;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
color: var(--reader-text);
}
/* Chapter body — content from API (could be HTML/markdown-rendered) */
.reader-chapter-body {
font-size: 1em;
line-height: var(--reader-line-height);
}
.reader-chapter-body p {
margin-bottom: 1.2em;
text-align: justify;
hyphens: auto;
}
.reader-chapter-body h2,
.reader-chapter-body h3,
.reader-chapter-body h4 {
margin-top: 1.5em;
margin-bottom: 0.6em;
line-height: 1.3;
}
.reader-chapter-body blockquote {
border-left: 3px solid var(--reader-accent);
padding-left: 1em;
margin: 1em 0;
opacity: 0.85;
font-style: italic;
}
.reader-chapter-body img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 1em 0;
}
.reader-chapter-body ul,
.reader-chapter-body ol {
padding-left: 1.5em;
margin-bottom: 1.2em;
}
.reader-chapter-body li {
margin-bottom: 0.4em;
}
/* --- Navigation Bar (Bottom) --- */
.reader-nav {
/* --- EPUB Reader (react-reader) --- */
.reader-epub-container {
position: fixed;
bottom: 0;
top: 55px;
left: 0;
right: 0;
height: 56px;
background: var(--reader-toolbar-bg);
color: var(--reader-toolbar-text);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
z-index: 120;
gap: 8px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
bottom: 0;
overflow: hidden;
height: calc(100vh - 55px);
isolation: isolate;
}
.reader-nav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: var(--reader-toolbar-text);
font-size: 0.85rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
.reader-epub-container > div {
height: 100% !important;
}
.reader-page-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
border: none;
background: none;
font-size: 48px;
line-height: 1;
padding: 0 10px;
color: rgba(184, 168, 152, 0.9);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.reader-nav-btn:hover:not(.reader-nav-btn--disabled) {
border-color: var(--reader-accent);
color: var(--reader-accent);
.reader-page-nav:hover {
color: rgba(138, 122, 106, 1);
}
.reader-nav-btn--disabled {
opacity: 0.3;
cursor: not-allowed;
.reader-page-nav--prev {
left: 4px;
}
.reader-nav-btn--back {
background: rgba(108, 140, 255, 0.15);
border-color: rgba(108, 140, 255, 0.3);
.reader-page-nav--next {
right: 4px;
}
.reader-nav-btn--back:hover {
background: rgba(108, 140, 255, 0.25);
color: var(--reader-accent);
.reader-resume-reading {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(calc(-50% - 56px));
z-index: 12;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 12px;
background: #ea580c;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
box-shadow: 0 4px 14px rgba(234, 88, 12, 0.45);
max-width: 72px;
text-align: center;
}
.reader-resume-reading:hover {
background: #c2410c;
}
.reader-resume-reading-icon {
font-size: 20px;
line-height: 1;
}
.reader-resume-reading-label {
display: block;
}
.reader-container[data-theme="dark"] .reader-page-nav {
color: rgba(102, 102, 102, 0.9);
}
.reader-container[data-theme="dark"] .reader-page-nav:hover {
color: rgba(170, 170, 170, 1);
}
.reader-container[data-theme="light"] .reader-page-nav,
.reader-container[data-theme="paper"] .reader-page-nav {
color: rgba(204, 204, 204, 0.9);
}
.reader-container[data-theme="light"] .reader-page-nav:hover,
.reader-container[data-theme="paper"] .reader-page-nav:hover {
color: rgba(136, 136, 136, 1);
}
/* --- Loading State --- */
.reader-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px;
color: var(--reader-text);
min-height: 100vh;
}
.reader-loading .spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(108, 140, 255, 0.2);
border-top-color: var(--reader-accent);
border-radius: 50%;
animation: readerSpin 0.8s linear infinite;
}
@keyframes readerSpin {
to { transform: rotate(360deg); }
}
.back-button {
padding: 10px 20px;
background: var(--reader-accent);
border: none;
border-radius: 8px;
color: white;
font-size: 0.9rem;
cursor: pointer;
font-family: var(--reader-ui-font);
}
.back-button:hover {
background: #5a7ae8;
}
.reader-error {
color: #ef4444;
text-align: center;
}
/* --- TOC Drawer --- */
.toc-overlay,
.settings-overlay {
position: fixed;
top: 0;
@@ -274,7 +294,6 @@
to { opacity: 1; }
}
.toc-drawer,
.settings-drawer {
position: fixed;
top: 0;
@@ -283,22 +302,11 @@
max-width: 85vw;
background: var(--reader-toolbar-bg);
color: var(--reader-toolbar-text);
font-family: var(--reader-ui-font);
z-index: 140;
display: flex;
flex-direction: column;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toc-drawer {
left: 0;
transform: translateX(-100%);
}
.toc-drawer--open {
transform: translateX(0);
}
.settings-drawer {
right: 0;
transform: translateX(100%);
}
@@ -307,7 +315,6 @@
transform: translateX(0);
}
.toc-header,
.settings-header {
display: flex;
align-items: center;
@@ -316,14 +323,12 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.toc-title,
.settings-title {
font-size: 1.1rem;
font-weight: 700;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: var(--reader-ui-font);
}
.toc-close-btn,
.settings-close-btn {
display: flex;
align-items: center;
@@ -338,12 +343,76 @@
transition: background 0.15s;
}
.toc-close-btn:hover,
.settings-close-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
/* --- TOC List --- */
/* --- TOC Drawer (custom, epub.js nav) --- */
.toc-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 130;
animation: fadeIn 0.2s ease;
}
.toc-drawer {
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 300px;
max-width: 85vw;
background: var(--reader-toolbar-bg);
color: var(--reader-toolbar-text);
font-family: var(--reader-ui-font);
z-index: 140;
display: flex;
flex-direction: column;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toc-drawer--open {
transform: translateX(0);
}
.toc-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 52px;
}
.toc-title {
font-size: 1.1rem;
font-weight: 700;
font-family: var(--reader-ui-font);
}
.toc-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
color: var(--reader-toolbar-text);
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.toc-close-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.toc-list {
flex: 1;
overflow-y: auto;
@@ -360,7 +429,6 @@
.toc-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 16px;
background: transparent;
@@ -370,32 +438,13 @@
text-align: left;
cursor: pointer;
transition: background 0.15s;
font-family: inherit;
font-family: var(--reader-ui-font);
}
.toc-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.toc-item--active {
background: rgba(108, 140, 255, 0.15);
color: var(--reader-accent);
font-weight: 600;
}
.toc-item-number {
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
font-size: 0.75rem;
font-weight: 700;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
padding: 0 4px;
}
.toc-item-title {
flex: 1;
white-space: nowrap;
@@ -421,7 +470,7 @@
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: var(--reader-ui-font);
}
/* Theme grid */
@@ -440,7 +489,7 @@
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
font-family: var(--reader-ui-font);
}
.theme-btn:hover {
@@ -472,7 +521,7 @@
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
font-family: var(--reader-ui-font);
}
.font-btn:hover,
@@ -536,7 +585,7 @@
transition: all 0.2s;
margin-top: 16px;
margin-bottom: 16px;
font-family: inherit;
font-family: var(--reader-ui-font);
}
.start-reading-btn:hover {
@@ -545,71 +594,175 @@
box-shadow: 0 4px 12px rgba(108, 140, 255, 0.3);
}
/* --- Loading State --- */
.reader-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px;
color: var(--reader-text);
}
.reader-error {
color: #ef4444;
text-align: center;
}
/* --- Responsive --- */
@media (max-width: 480px) {
.reader-chapter {
max-width: 100%;
}
.reader-nav-btn {
padding: 8px 10px;
font-size: 0.8rem;
}
.reader-nav-btn span {
display: none;
}
.toc-drawer,
.settings-drawer {
.settings-drawer,
.toc-drawer {
max-width: 100vw;
}
.reader-chapter-title {
font-size: 1.3em;
.reader-epub-container {
top: 52px;
height: calc(100vh - 52px);
}
}
@media (min-width: 768px) {
.reader-chapter {
max-width: 720px;
}
.reader-chapter-body {
font-size: 1.05em;
}
}
/* --- Landscape on Mobile --- */
@media (orientation: landscape) and (max-height: 500px) {
.reader-top-bar {
height: 44px;
}
.reader-content {
padding-top: 60px;
padding-bottom: 60px;
.reader-epub-container {
top: 47px;
height: calc(100vh - 47px);
}
}
.reader-chapter-title {
font-size: 1.2em;
}
/* --- Selection popover --- */
.selection-popover-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: transparent;
}
.selection-popover {
position: fixed;
z-index: 210;
width: min(280px, calc(100vw - 24px));
padding: 12px;
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
border: 1px solid #e5e7eb;
}
.selection-popover-quote {
font-size: 0.8125rem;
font-style: italic;
color: #374151;
margin: 0 0 10px;
line-height: 1.5;
}
.bookmark-color-picker {
margin-bottom: 10px;
}
.bookmark-color-picker-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 6px;
}
.bookmark-color-picker-swatches {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.bookmark-color-swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.bookmark-color-swatch--active {
border-color: #1f2937;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #1f2937;
}
.bookmark-color-custom {
position: relative;
display: inline-flex;
cursor: pointer;
}
.bookmark-color-custom-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.bookmark-color-swatch--custom {
pointer-events: none;
}
.selection-popover-input {
width: 100%;
box-sizing: border-box;
padding: 8px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.875rem;
resize: vertical;
font-family: var(--reader-ui-font);
}
.selection-popover-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
/* --- Reader side panel (markers) --- */
.reader-panel-backdrop {
position: fixed;
inset: 0;
z-index: 140;
background: rgba(0, 0, 0, 0.35);
}
.reader-side-panel {
position: fixed;
top: 55px;
right: 0;
bottom: 0;
width: min(360px, 100vw);
z-index: 150;
background: #fff;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
}
.reader-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #eee;
}
.reader-panel-header h2 {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.reader-panel-close {
border: none;
background: none;
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
color: #666;
}
.reader-panel-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
/* --- Dark mode theme override when reader uses light theme but system is dark --- */
+129
View File
@@ -411,6 +411,135 @@ body {
margin-top: 0.5rem;
}
/* Marker threads (bookmarks-notes page) */
.marker-books-list {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.marker-book-group {
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
background: var(--color-surface);
}
.marker-book-header {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border: none;
background: #f8fafc;
cursor: pointer;
text-align: left;
font: inherit;
}
.marker-book-title {
font-weight: 700;
font-size: 1rem;
flex: 1;
}
.marker-book-count {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.marker-book-chevron {
color: var(--color-text-muted);
}
.marker-thread-list {
list-style: none;
margin: 0;
padding: 0.75rem 1.25rem 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.marker-thread {
padding-left: 0.75rem;
border-left: 2px solid #e5e7eb;
}
.marker-thread-chapter {
font-size: 0.8125rem;
font-weight: 600;
color: #4f46e5;
display: block;
margin-bottom: 0.35rem;
}
.marker-thread-passage {
margin: 0.25rem 0 0.5rem;
}
.marker-passage-text {
display: block;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.marker-passage-text--clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
white-space: normal;
}
.marker-thought-wrap {
margin: 0.5rem 0 0.25rem 1rem;
}
.marker-thought-text {
margin: 0;
}
.marker-thought-text--clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.marker-text-toggle {
display: inline-block;
margin-top: 0.35rem;
padding: 0;
border: none;
background: none;
font-size: 0.75rem;
font-weight: 600;
color: #4f46e5;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.marker-text-toggle:hover {
color: #3730a3;
}
.marker-thread-thought {
padding: 0.65rem 0.85rem;
background: #f9fafb;
border-radius: var(--radius-sm);
font-size: 0.9375rem;
line-height: 1.55;
white-space: pre-wrap;
}
.marker-thread-reply {
border-left: 2px solid #d1d5db;
}
/* Responsive */
@media (max-width: 640px) {
.app-header {
+10 -1
View File
@@ -32,6 +32,14 @@ export interface EBookListItem {
cover_image: string | null;
created_at: string;
progress: number | null;
started?: boolean;
}
export interface OpenLibraryMetadata {
source?: string;
match_status?: "matched" | "not_found" | string;
match_score?: number;
matched_at?: string;
}
export interface EBookDetail {
@@ -43,7 +51,7 @@ export interface EBookDetail {
format: string;
page_count: number;
file_size: number;
metadata_json: Record<string, unknown>;
metadata?: OpenLibraryMetadata & Record<string, unknown>;
cover_image: string | null;
created_at: string;
updated_at: string;
@@ -53,6 +61,7 @@ export interface EBookDetail {
export interface ReadingProgress {
current_position: number;
last_page: number;
epub_location?: string;
}
export interface ReadingSettings {
+30 -19
View File
@@ -26,10 +26,15 @@ export interface BookSummary {
export interface Bookmark {
id: string;
book: string;
book_title: string;
ebook: number;
ebook_title: string;
epub_cfi: string;
chapter_index: number;
chapter_title: string;
page: number;
location_text: string;
content: string;
highlight_color?: string;
created_at: string;
updated_at: string;
}
@@ -45,14 +50,17 @@ export interface Note {
updated_at: string;
}
/** Payload for creating a new bookmark */
export interface CreateBookmarkPayload {
book: string;
page: number;
export interface CreateMarkerPayload {
ebook: number;
epub_cfi: string;
chapter_index: number;
chapter_title?: string;
location_text?: string;
content?: string;
highlight_color?: string;
}
/** Payload for creating a new note */
/** @deprecated Legacy note create */
export interface CreateNotePayload {
book: string;
page: number;
@@ -60,12 +68,10 @@ export interface CreateNotePayload {
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;
@@ -73,23 +79,28 @@ export interface PaginatedResponse<T> {
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 {
export interface MarkerEntry {
id: string;
kind: AnnotationKind;
book_title: string;
book_id: string;
ebook_id: number;
ebook_title: string;
epub_cfi: string;
chapter_index: number;
chapter_title: string;
page: number;
location_text: string;
content?: string; // notes only
content: string;
highlight_color?: string;
created_at: string;
updated_at: string;
}
}
export interface MarkersByBook {
ebookId: number;
ebookTitle: string;
markers: MarkerEntry[];
}
+1
View File
@@ -40,5 +40,6 @@ export interface ReadingProgress {
current_chapter: number;
current_position: number;
percentage: number;
epub_location: string;
updated_at: string;
}
+95
View File
@@ -0,0 +1,95 @@
import type { EpubRendition } from "./epubRendition";
import { normalizeHighlightColor } from "@/constants/bookmarkHighlightColors";
export interface BookmarkHighlight {
id: string;
epub_cfi: string;
highlight_color?: string | null;
}
type EpubAnnotations = {
highlight?: (
cfiRange: string,
data: Record<string, unknown>,
callback?: () => void,
id?: string,
styles?: Record<string, string>,
) => void;
remove?: (cfiRange: string, type: string) => void;
};
export type EpubRenditionWithHighlights = EpubRendition & {
annotations?: EpubAnnotations;
on?: (event: string, handler: () => void) => void;
off?: (event: string, handler: () => void) => void;
};
function annotationId(bookmarkId: string): string {
return `bookmark-hl-${bookmarkId}`;
}
export function applyBookmarkHighlight(
rendition: EpubRenditionWithHighlights,
bookmark: BookmarkHighlight,
): void {
const cfi = (bookmark.epub_cfi || "").trim();
if (!cfi || !rendition.annotations?.highlight) return;
const color = normalizeHighlightColor(bookmark.highlight_color);
try {
rendition.annotations.highlight(
cfi,
{},
undefined,
annotationId(bookmark.id),
{
fill: color,
"fill-opacity": "0.45",
"mix-blend-mode": "multiply",
},
);
} catch {
/* CFI may not be in the current spine slice */
}
}
export function removeBookmarkHighlight(
rendition: EpubRenditionWithHighlights,
bookmark: BookmarkHighlight,
): void {
const cfi = (bookmark.epub_cfi || "").trim();
if (!cfi || !rendition.annotations?.remove) return;
try {
rendition.annotations.remove(cfi, "highlight");
} catch {
/* ignore */
}
}
export function syncBookmarkHighlights(
rendition: EpubRenditionWithHighlights | null,
bookmarks: BookmarkHighlight[],
applied: Map<string, string>,
): void {
if (!rendition?.annotations?.highlight) return;
const nextById = new Map(bookmarks.map((b) => [b.id, b]));
for (const [id, cfi] of applied) {
if (!nextById.has(id)) {
removeBookmarkHighlight(rendition, { id, epub_cfi: cfi });
applied.delete(id);
}
}
for (const bookmark of bookmarks) {
const prevCfi = applied.get(bookmark.id);
if (prevCfi && prevCfi !== bookmark.epub_cfi) {
removeBookmarkHighlight(rendition, { id: bookmark.id, epub_cfi: prevCfi });
applied.delete(bookmark.id);
}
if (applied.has(bookmark.id)) continue;
applyBookmarkHighlight(rendition, bookmark);
applied.set(bookmark.id, bookmark.epub_cfi);
}
}
+151
View File
@@ -0,0 +1,151 @@
import type { ReadingSettings } from "../types/reader";
type EpubSpine = {
get: (target: string | number) => { href: string } | null;
spineItems?: Array<{ href: string }>;
};
type EpubBookForNav = {
ready: Promise<void>;
spine: EpubSpine;
};
type EpubRendition = {
themes: {
register: (name: string, styles: { body: Record<string, string> }) => void;
select: (name: string) => void;
fontSize: (size: string) => void;
override: (property: string, value: string, important?: boolean) => void;
};
book: EpubBookForNav & {
locations: {
generate: (size: number) => Promise<string[]>;
percentageFromCfi: (cfi: string) => number | null;
};
navigation?: {
get: (href: string) => { label?: string } | null;
};
};
currentLocation: () => Promise<{ start?: { href?: string } } | null>;
display: (target: string) => Promise<unknown>;
next: () => void;
prev: () => void;
};
type EpubManager = {
settings: { gap?: number };
isRendered?: () => boolean;
updateLayout?: () => void;
};
type EpubRenditionWithManager = EpubRendition & {
settings?: { gap?: number };
manager?: EpubManager;
};
const FONT_STACKS: Record<ReadingSettings["font_family"], string> = {
serif: 'Georgia, "Times New Roman", serif',
"sans-serif": 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
monospace: 'ui-monospace, "Cascadia Code", monospace',
};
const READER_THEME_KEY = "cloud-reader";
export function isEpubCfi(location: string | number): boolean {
return typeof location === "string" && location.startsWith("epubcfi(");
}
/** Map nav/TOC href to a spine href epub.js can display. */
export function resolveSpineHref(book: EpubBookForNav, href: string): string {
const hashIndex = href.indexOf("#");
const fragment = hashIndex >= 0 ? href.slice(hashIndex) : "";
const path = hashIndex >= 0 ? href.slice(0, hashIndex) : href;
const candidates = [href, path];
try {
candidates.push(decodeURIComponent(path), encodeURI(path));
} catch {
/* ignore malformed URI encoding in nav hrefs */
}
for (const candidate of candidates) {
if (book.spine.get(candidate)) {
return candidate.includes("#") ? candidate : candidate + fragment;
}
}
const normalizedPath = path.replace(/^\/+/, "");
const pathFile = normalizedPath.split("/").pop() ?? normalizedPath;
for (const item of book.spine.spineItems ?? []) {
const spineHref = item.href.replace(/^\/+/, "");
const spineFile = spineHref.split("/").pop() ?? spineHref;
if (
spineHref === normalizedPath ||
spineHref.endsWith(normalizedPath) ||
normalizedPath.endsWith(spineHref) ||
spineFile === pathFile
) {
return item.href + fragment;
}
}
return href;
}
/** epub.js paginated side padding is gap/2; map per-side margin to total gap. */
export function marginWidthToGap(marginWidth: number): number {
return marginWidth * 2;
}
function applyEpubGap(rendition: EpubRendition, marginWidth: number): void {
const gap = marginWidthToGap(marginWidth);
const r = rendition as EpubRenditionWithManager;
if (r.settings) {
r.settings.gap = gap;
}
if (r.manager?.settings) {
r.manager.settings.gap = gap;
}
if (r.manager?.isRendered?.() && r.manager.updateLayout) {
r.manager.updateLayout();
}
}
/** Apply reading preferences inside the epub.js iframe. */
export function applyRenditionSettings(
rendition: EpubRendition,
settings: ReadingSettings,
): void {
const fontStack = FONT_STACKS[settings.font_family] ?? FONT_STACKS.serif;
applyEpubGap(rendition, settings.margin_width);
rendition.themes.register(READER_THEME_KEY, {
body: {
color: `${settings.text_color} !important`,
background: `${settings.background_color} !important`,
"font-family": `${fontStack} !important`,
"line-height": `${settings.line_height} !important`,
},
});
rendition.themes.select(READER_THEME_KEY);
rendition.themes.fontSize(`${settings.font_size}px`);
}
export async function updateChapterTitleFromRendition(
rendition: EpubRendition,
): Promise<string> {
try {
const loc = await rendition.currentLocation();
const href = loc?.start?.href;
if (href && rendition.book.navigation) {
const nav = rendition.book.navigation.get(href);
if (nav?.label) return nav.label;
}
} catch {
/* ignore navigation lookup failures */
}
return "";
}
export type { EpubRendition };
+35
View File
@@ -0,0 +1,35 @@
export const FINISHED_PROGRESS_THRESHOLD = 99;
export type LibraryReadingStatus = "want_to_read" | "reading" | "finished";
/** API may return progress as a number or numeric string. */
export function coerceProgressPercent(progress: unknown): number | null {
if (progress == null || progress === "") return null;
const n = typeof progress === "number" ? progress : Number(progress);
if (!Number.isFinite(n)) return null;
return n;
}
/**
* @param started True when the user has a saved EPUB location (reader was used).
*/
export function deriveReadingStatus(
progress: unknown,
started = false,
): LibraryReadingStatus {
const pct = coerceProgressPercent(progress);
if (pct != null && pct >= FINISHED_PROGRESS_THRESHOLD) return "finished";
if (pct != null && pct > 0) return "reading";
if (started) return "reading";
return "want_to_read";
}
export function normalizeProgressPercent(
progress: unknown,
readingStatus: LibraryReadingStatus,
): number | null {
const pct = coerceProgressPercent(progress);
if (readingStatus !== "reading" && readingStatus !== "finished") return null;
if (pct == null || pct <= 0) return null;
return Math.min(100, Math.max(0, Math.round(pct)));
}
+50
View File
@@ -0,0 +1,50 @@
import { DEFAULT_BOOKMARK_HIGHLIGHT_COLOR } from "@/constants/bookmarkHighlightColors";
import { DEFAULT_BOOKMARK_HIGHLIGHT_COLOR } from "@/constants/bookmarkHighlightColors";
import type { Bookmark, MarkerEntry, MarkersByBook } from "@/types";
export function bookmarkToMarkerEntry(b: Bookmark): MarkerEntry {
return {
id: b.id,
ebook_id: b.ebook,
ebook_title: b.ebook_title,
epub_cfi: b.epub_cfi,
chapter_index: b.chapter_index,
chapter_title: b.chapter_title,
page: b.page,
location_text: b.location_text,
content: b.content,
highlight_color: b.highlight_color ?? DEFAULT_BOOKMARK_HIGHLIGHT_COLOR,
created_at: b.created_at,
updated_at: b.updated_at,
};
}
export function sortMarkers(a: MarkerEntry, b: MarkerEntry): number {
if (a.chapter_index !== b.chapter_index) {
return a.chapter_index - b.chapter_index;
}
return a.epub_cfi.localeCompare(b.epub_cfi);
}
export function groupMarkersByBook(bookmarks: Bookmark[]): MarkersByBook[] {
const map = new Map<number, MarkersByBook>();
for (const b of bookmarks) {
const entry = bookmarkToMarkerEntry(b);
const existing = map.get(b.ebook);
if (existing) {
existing.markers.push(entry);
} else {
map.set(b.ebook, {
ebookId: b.ebook,
ebookTitle: b.ebook_title,
markers: [entry],
});
}
}
const groups = Array.from(map.values());
for (const g of groups) {
g.markers.sort(sortMarkers);
}
groups.sort((a, b) => a.ebookTitle.localeCompare(b.ebookTitle));
return groups;
}
+65
View File
@@ -0,0 +1,65 @@
import { ReactReaderStyle, type IReactReaderStyle } from "react-reader";
import type { ReadingSettings } from "../types/reader";
const THEME_ARROWS: Record<ReadingSettings["theme"], string> = {
sepia: "#b8a898",
dark: "#666666",
light: "#cccccc",
paper: "#c4b8a8",
};
const THEME_ARROW_HOVER: Record<ReadingSettings["theme"], string> = {
sepia: "#8a7a6a",
dark: "#aaaaaa",
light: "#888888",
paper: "#7a6a5a",
};
/** Build complete react-reader styles — must spread ReactReaderStyle; partial objects break layout. */
export function buildReaderStyles(settings: ReadingSettings): IReactReaderStyle {
return {
...ReactReaderStyle,
container: {
...ReactReaderStyle.container,
height: "100%",
width: "100%",
},
containerExpanded: ReactReaderStyle.containerExpanded,
readerArea: {
...ReactReaderStyle.readerArea,
backgroundColor: settings.background_color,
transition: undefined,
},
titleArea: {
...ReactReaderStyle.titleArea,
display: "none",
},
reader: {
...ReactReaderStyle.reader,
top: 0,
left: 0,
right: 0,
bottom: 0,
},
arrow: {
...ReactReaderStyle.arrow,
color: THEME_ARROWS[settings.theme],
fontSize: 48,
marginTop: -24,
},
arrowHover: {
...ReactReaderStyle.arrowHover,
color: THEME_ARROW_HOVER[settings.theme],
},
tocButton: {
...ReactReaderStyle.tocButton,
display: "none",
},
tocArea: ReactReaderStyle.tocArea,
tocAreaButton: ReactReaderStyle.tocAreaButton,
loadingView: {
...ReactReaderStyle.loadingView,
color: "#999",
},
};
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/annotations.ts","./src/api/books.ts","./src/api/client.ts","./src/api/errors.ts","./src/api/reader.ts","./src/components/bookcontextmenu.tsx","./src/components/toastcontainer.tsx","./src/components/annotations/bookmarksnotespage.tsx","./src/components/annotations/markerthreadsview.tsx","./src/components/annotations/index.ts","./src/components/layout/layout.tsx","./src/components/layout/index.ts","./src/components/reader/bookmarkerspanel.tsx","./src/components/reader/readertoolbar.tsx","./src/components/reader/readingsettingspanel.tsx","./src/components/reader/selectionpopover.tsx","./src/components/reader/tableofcontents.tsx","./src/components/search/searchsuggestions.tsx","./src/context/annotationscontext.tsx","./src/context/authcontext.tsx","./src/hooks/index.ts","./src/hooks/usedebounce.ts","./src/hooks/useepubreader.ts","./src/hooks/useepubselection.ts","./src/hooks/usemediaquery.ts","./src/hooks/usepaginatedquery.ts","./src/hooks/usereadingsettings.ts","./src/hooks/usetoast.tsx","./src/hooks/usevoicesearch.ts","./src/i18n/i18nprovider.tsx","./src/locales/en-us.ts","./src/locales/es-es.ts","./src/locales/index.ts","./src/pages/addbook.tsx","./src/pages/authpage.tsx","./src/pages/bookdetailpage.tsx","./src/pages/library.tsx","./src/pages/readingpage.tsx","./src/pages/settings.tsx","./src/types/book.ts","./src/types/index.ts","./src/types/reader.ts","./src/types/speech-recognition.d.ts","./src/types/__tests__/types.test.ts","./src/utils/epubrendition.ts","./src/utils/librarystatus.ts","./src/utils/markers.ts","./src/utils/reactreadertheme.ts","./vite-env.d.ts"],"version":"5.7.3"}
+4
View File
@@ -16,6 +16,10 @@ export default defineConfig({
target: "http://localhost:8000",
changeOrigin: true,
},
"/media": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
});