Archived
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:
+1
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -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
+22
File diff suppressed because one or more lines are too long
@@ -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
@@ -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};
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
+70
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
Vendored
+13
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</button>
|
||||
<h3>Add to Page {page}</h3>
|
||||
{locationText && (
|
||||
<blockquote className="annotation-quote">
|
||||
“{locationText}”
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
{!mode ? (
|
||||
<div className="mode-selector">
|
||||
<button
|
||||
className="btn btn-block"
|
||||
onClick={() => setMode("bookmark")}
|
||||
>
|
||||
Add Bookmark
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-block btn-secondary"
|
||||
onClick={() => setMode("note")}
|
||||
>
|
||||
Add Note
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="annotation-form">
|
||||
{mode === "note" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="note-content">Note:</label>
|
||||
<textarea
|
||||
id="note-content"
|
||||
className="form-textarea"
|
||||
value={noteContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setNoteContent(e.target.value)
|
||||
}
|
||||
rows={5}
|
||||
placeholder="Write your note here..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setMode(null)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">·</span>
|
||||
<span className="summary-count">
|
||||
{state.notes.length} notes
|
||||
</span>
|
||||
</div>
|
||||
<div className="annotations-list">
|
||||
{mergedAnnotations.map((entry: AnnotationEntry) => (
|
||||
<div key={`${entry.kind}-${entry.id}`} className="annotation-card">
|
||||
<div className="annotation-card-header">
|
||||
<span
|
||||
className={`annotation-kind-badge ${
|
||||
entry.kind === "bookmark" ? "bookmark-badge" : "note-badge"
|
||||
}`}
|
||||
>
|
||||
{entry.kind === "bookmark" ? "Bookmark" : "Note"}
|
||||
</span>
|
||||
<span className="annotation-book-title">
|
||||
{entry.book_title}
|
||||
</span>
|
||||
<span className="annotation-page">p.{entry.page}</span>
|
||||
<span className="annotation-date">
|
||||
{new Date(entry.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{entry.location_text && (
|
||||
<blockquote className="annotation-quote">
|
||||
“{entry.location_text}”
|
||||
</blockquote>
|
||||
)}
|
||||
{entry.kind === "note" && entry.content && (
|
||||
<p className="note-content-text">{entry.content}</p>
|
||||
)}
|
||||
<div className="annotation-actions">
|
||||
{onNavigateToPage && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() =>
|
||||
onNavigateToPage(entry.book_id, entry.page)
|
||||
}
|
||||
>
|
||||
Go to page
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(entry)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
“{bookmark.location_text}”
|
||||
</blockquote>
|
||||
)}
|
||||
<div className="annotation-actions">
|
||||
{onNavigateToPage && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() =>
|
||||
onNavigateToPage(bookmark.book, bookmark.page)
|
||||
}
|
||||
>
|
||||
Go to page
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(bookmark.id)}
|
||||
disabled={deletingId === bookmark.id}
|
||||
>
|
||||
{deletingId === bookmark.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
“{text}”
|
||||
</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">
|
||||
“{note.location_text}”
|
||||
</blockquote>
|
||||
)}
|
||||
<div className="annotation-note-content">
|
||||
{editingId === note.id ? (
|
||||
<div className="note-edit-form">
|
||||
<textarea
|
||||
className="note-edit-textarea"
|
||||
value={editContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setEditContent(e.target.value)
|
||||
}
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="note-edit-actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleSave(note.id)}
|
||||
disabled={savingId === note.id || !editContent.trim()}
|
||||
>
|
||||
{savingId === note.id ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="note-content-text">{note.content}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="annotation-actions">
|
||||
{onNavigateToPage && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() =>
|
||||
onNavigateToPage(note.book, note.page)
|
||||
}
|
||||
>
|
||||
Go to page
|
||||
</button>
|
||||
)}
|
||||
{editingId !== note.id && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleEdit(note)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(note.id)}
|
||||
disabled={deletingId === note.id}
|
||||
>
|
||||
{deletingId === note.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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)}>“{quoteText}”</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 */
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 --- */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -40,5 +40,6 @@ export interface ReadingProgress {
|
||||
current_chapter: number;
|
||||
current_position: number;
|
||||
percentage: number;
|
||||
epub_location: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/media": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user