ui/ux: codify design system and migrate settings to shared primitives (#4116)

* ui/ux: codify design system and migrate settings to shared primitives

Document Readest's design language in DESIGN.md (Adwaita-aligned, e-ink-first,
RTL-correct) and migrate every settings panel onto a small set of primitives
(BoxedList, SettingsRow, SettingsSwitchRow, SettingsSelect, SettingsInput,
NavigationRow, Tips, SubPageHeader). AGENTS.md links to DESIGN.md so contributors
land there before inventing new chassis classes.

Replace the standalone KOReader/Readwise/Hardcover Config dialogs with a single
Integrations panel (Reading Sync + Content Sources sub-pages). The reader's
BookMenu now hides each provider until it's configured, and Hardcover's per-book
"Enable for This Book" toggle is dropped — there's no auto-sync to gate, so the
flag was just extra clicks.

Refresh highlight colors (two-trigger swatch + label, translatable default
names), background texture / theme color selectors (border-current keeps
selection legible on any backdrop), CustomFonts/CustomDictionaries (quiet
list-extension style + shared Tips primitive), the OPDS catalog manager
(debounced auto-download, right-aligned Browse), Set PIN, and the KOSync
conflict resolver. Translate the ~30 new strings across all 33 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: responsive typography, OPDS card polish, deep-link return paths

Restore the .settings-content responsive cascade (14px desktop / 16px
mobile) the legacy panels relied on by dropping hardcoded `text-sm`/`text-xs`
from the new primitives. Secondary text moves to em-relative `text-[0.85em]`
so it scales with the parent. Form controls (`<input>`, `<select>`) re-apply
the cascade explicitly via the `settings-content` class since browsers don't
inherit font-size onto form elements.

Extract `<SectionTitle>` primitive (caseless-language aware via
`isCaselessUILang`/`isCaselessLang`) and route every uppercase tag-style
header through it: BoxedList groups, Reading Sync, Content Sources, Theme
Color, Background Image, integration form labels, KOSyncResolver device
labels, and the OPDS My Catalogs / Popular Catalogs sections. CJK / Arabic
/ Hebrew / Indic / Thai / Tibetan locales bump to `1em` since `uppercase`
is a no-op on those scripts.

Redesign the OPDS My Catalogs cards: whole card becomes the browse trigger
(role='button'), edit/delete collapse into a 3-dot dropdown menu, and the
sync-status moves to a sub-line under Auto-download so the card height stays
constant whether the toggle is on/off or sync data has arrived.

Plumb a `from=settings-integrations` URL marker through the OPDS browser so
both manual close and auto-close-on-failure (preserved as `router.back()`
for transient failures, paired with a new `stashOPDSReturnTarget` helper)
return the user to Settings -> Integrations -> OPDS Catalogs sub-page
rather than the dialog's top level. Backed by new `requestedSubPage`
deep-link store field.

Skip the OPDS catalog passphrase prompt when credentials sync is disabled
-- `replicaPublish` already drops encrypted fields at the wire, so prompting
was both pointless and confusing.

Fix `SettingsDialog` calling `setRequestedPanel(null)` inside a `useState`
lazy initializer (zustand setter during render -> React warning); move the
clear into a one-shot `useEffect`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: opt Settings into OverlayScrollbars + caseless typography polish

Add an opt-in `useOverlayScroll` prop to `<Dialog>` that swaps the body's
native `overflow-y-auto` for `<OverlayScrollbarsComponent>` (autohide,
click-scroll, no native overlaid bars). SettingsDialog flips it on so the
long Layout / Color panels keep a visible, theme-aware scroll track on
Android / iOS webviews where native scrollbars auto-hide entirely. Other
short-modal callers stay on the native scrollbar.

Drop the `uppercase tracking-wider` SectionTitle styling for caseless
scripts and pair it with body-weight `font-medium` instead — those
typographic effects are no-ops on Han / Hangul / Devanagari / Thai etc.,
so a plain medium-weight body-size title reads more correctly than a
shrunken pseudo-uppercase one. SettingsRow / NavigationRow primary labels
follow the same rule (drop `font-medium` in caseless locales since the
inherited body weight already carries; CJK fonts bold poorly at body
size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: SettingLabel primitive + KOSyncForm select polish + Tips alignment

Add `<SettingLabel>` primitive — caseless-aware row/field label that pairs
with `<SectionTitle>` (groups) for per-item labels. Cased scripts get
`font-medium`; caseless scripts (CJK / Arabic / Hebrew / Indic / Thai /
Tibetan) drop the weight since Han / Hangul / Devanagari etc. bold poorly
at body size. No font-size class so it inherits the `.settings-content`
14/16 cascade. Routed through `SettingsRow`, `NavigationRow`, and the
~12 ad-hoc inline `text-sm font-medium` callsites in AIPanel / FontPanel
/ ColorPanel / IntegrationsPanel / KOSync / Readwise / Hardcover forms.

Refactor KOSyncForm's Sync Strategy + Checksum Method rows onto the
shared `<SettingsSelect>` primitive — the inline 17-line div/select/
MdArrowDropDown chassis becomes a single SettingsSelect call with an
options array. Drops the unused MdArrowDropDown import and ~25 lines.

Fix Tips list-item alignment: callers traditionally pass `<li>` elements
(semantic) but the primitive was double-wrapping into `<li><span><li>...</li></span></li>` — invalid HTML, and the inner `<li>`'s
`display: list-item` broke line-wrap alignment on multi-line items.
Unwrap caller `<li>` to its content; add `flex-1` on the text span so
wrapped lines align under the first line instead of falling back to the
bullet column. Bullet container switches to `h-[1.4em]` so it tracks the
text line-height and pins to the first line's optical center via
`items-center` regardless of how much the content wraps.

DESIGN.md §5 typography updated to point primary-label callers at
`<SettingLabel>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-10 23:46:25 +08:00
committed by GitHub
parent 5774e00c09
commit 772bb73b46
100 changed files with 5555 additions and 2963 deletions
@@ -24,6 +24,7 @@
## Patterns
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
@@ -0,0 +1,49 @@
---
name: Design rules live in DESIGN.md
description: Readest has a design system doc — codify recurring UI/UX rules there, don't just apply them ad-hoc. Memory points at the canonical location and the patterns it covers.
type: feedback
originSessionId: 85757e57-a029-40f8-b098-88039c43514b
---
The project's design system is documented at `apps/readest-app/DESIGN.md`.
**When the user articulates a UI/UX rule** ("X should always Y", "we follow Z
convention"), add it to DESIGN.md so it persists for the team and for future
sessions — don't just apply it inline and move on.
**Why:** Readest's UI is Adwaita-aligned, e-ink-first, cross-platform-aware.
A doc'd system avoids drift across panels and gives reviewers a reference
point. The user explicitly asked to "remember the UI/UX rules somewhere"
when refining the OPDS Integration sub-page.
**How to apply:** When the user surfaces a new rule:
1. Add it to the appropriate `DESIGN.md` section (numbered principles in §2,
anatomy details in §5, anti-patterns in §10).
2. Cross-reference from related sections so it's discoverable from multiple
entry points.
3. Save the actual code change reference if it captures a canonical example.
**Rules already codified there (don't re-invent — reference instead):**
- §2.12.7: surface continuity, color discipline, two-step depth, localized
hover, motion=color, eink-first, focus visibility.
- §2.8: RTL — always use logical properties (`ps`/`pe`/`ms`/`me`/`text-start`
/`text-end`/`border-s`/`border-e`/`start-*`/`end-*`). Never `pl`/`pr`/`ml`
/`mr`/`text-left`/`text-right`/`left-*`/`right-*`. The user is strict on
this — Readest ships RTL languages.
- §2.9: every panel/sub-page must open with title + one-line description.
- §3: surface tier hierarchy (window/view/card → base-200/100-tinted/100).
- §4: action vocabulary (Accent CTA, Suggested, Flat, Pill, Destructive,
ListExtension).
- §5: boxed list anatomy + uniform row height (`min-h-14 items-center`,
never `py-3`) + chromeless controls inside the box + end-aligned values
with custom `<MdArrowDropDown>` icon (don't trust daisyui's bg-image
chevron for trailing-edge alignment).
- §8: e-ink overlay rules.
- §10: anti-pattern catalog with real before/after examples.
**Common file paths to remember:**
- `apps/readest-app/DESIGN.md` — source of truth.
- `apps/readest-app/src/components/settings/SubPageHeader.tsx` — title +
description sub-page primitive that embodies §2.9.
- `apps/readest-app/src/components/settings/integrations/` — the canonical
reference implementation of the boxed-list-with-rows pattern.
- `apps/readest-app/src/styles/globals.css` — eink overlay rules at
`[data-eink='true']`.
+4
View File
@@ -77,6 +77,10 @@ See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `s
See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges.
### Design System
UI/UX rules — surface tiers, action vocabulary, settings primitives (`BoxedList`, `SettingsRow`, `SettingsSwitchRow`, `SettingsSelect`, `NavigationRow`, `Tips`, etc.), boxed-list anatomy, RTL conventions, e-ink overlay, and anti-patterns — live in [DESIGN.md](DESIGN.md). Codify recurring decisions there so they persist for the team and future contributors. Reach for the primitives in `src/components/settings/primitives/` instead of inlining chassis classes.
### E-ink mode
Every new UI widget must look right under `[data-eink='true']`. E-ink screens have no shadows, no gradients, slow refresh, and need crisp 1px borders for delineation. The conventions live in `src/styles/globals.css` — reuse the existing classes instead of inventing new ones:
+972
View File
@@ -0,0 +1,972 @@
## Readest Design Language
Readest's UI is **Adwaita-aligned**, **e-ink-first**, **cross-platform-aware**. This doc is the
reference for that language: principles, vocabulary, anti-patterns. New work should read it
before reaching for daisyui defaults; existing work is gradually migrating toward it.
### Status
This doc is the **first articulation** of the system, not a retrospective. Many existing
components don't fully match it yet (especially older buttons and ad-hoc panels). The goal
is that **new code uses these conventions** and **migrations land opportunistically** as
features get touched.
---
### 1. Identity & lineage
Readest's visual language descends from **Adwaita / libadwaita** — GNOME's design system —
adapted for a cross-platform Tauri + Next.js app that also runs on iOS, Android, web, and
e-ink readers.
What we take from Adwaita:
- **Content first, chrome recedes.** The reading surface is the product. Settings, toolbars,
popups never compete with the page.
- **Boldly minimal.** Restraint over density. Whitespace is structural.
- **Surface hierarchy** — window → view → card — three explicit elevation tiers, no shadow
gymnastics.
- **Color discipline.** Brand color is rare and earned. Neutral palette carries the weight.
- **Boxed lists are the chassis.** AdwActionRow's prefix · title · suffix anatomy is the
canonical settings/list row everywhere.
- **Pills, ghosts, flats.** Three-tier button palette: pill/circular ghost in headers, flat
secondary over view-bg, accent CTA only when truly primary.
- **Banner vs Toast.** AdwBanner = inline, top-of-window, persistent. AdwToast = transient,
bottom slide-in.
- **Switches over checkboxes** for boolean settings.
- **Subtle motion.** Short, ease-out, never bouncy.
What's Readest-specific:
- **E-ink as a first-class mode.** Every surface flips to flat 1px contrast borders under
`[data-eink='true']`. Adwaita is desktop-GNOME-only; we ship to e-ink readers and the
visual language has to survive there.
- **Cross-platform reality.** Readest runs on macOS, Windows, Linux, iOS, Android, web. The
identity stays Adwaita; platform grace notes (radii, target sizes) follow host
conventions where they matter.
---
### 2. Principles
The seven rules. When in doubt, work backward from these.
#### 2.1 Surfaces continue surfaces
A control that extends a list/card should match its parent's border + fill. The
"+ Import Dictionary" button at `src/components/settings/CustomDictionaries.tsx` reads as
detached card siblings of the dictionary list above it because they share
`border-base-200 bg-base-100 rounded-lg`.
> **Bad**: a list of dictionaries in a `bg-base-100` card, followed by a `btn-outline btn-primary`
> add button. The button shouts; the list whispers; the eye bounces.
>
> **Good**: list and add-button share the same surface vocabulary. The eye flows.
#### 2.2 Color is earned
Brand `primary` is reserved for **the** primary action of a surface. Most actions don't have
a primary action — they have a list of equally-weighted choices, or a single accent.
- Settings dialog has no primary. Every panel is a list of toggles. **Zero brand color.**
- "Import a Book" in onboarding is a primary CTA. **One brand color.**
- "Add Web Search" extends a list — it's not the surface's primary action. **Neutral.**
#### 2.3 Two-step depth
State changes cycle through **`base-100 → base-200 → base-300`** instead of recoloring.
Hover lifts, active deepens, disabled fades opacity. This is theme-safe (works across all
11 color themes), e-ink-friendly (depth is preserved as borders, not shades), and
calmer than recoloring.
#### 2.4 Localize the hover signal
When a button hovers, **one focal element changes**, not the whole button. The icon chip
inverts; the label stays steady. The badge intensifies; the row stays neutral. This reads
as deliberate, not decorative.
#### 2.5 Motion is color, not transform
Default to `transition-colors duration-150`. No `scale`, no `translate`, no `rotate` unless
the motion **is** the message (a chevron rotating to indicate expansion is fine; a button
that scales on hover is not). Transforms break under `[data-eink='true']` and feel
gimmicky under Adwaita's calm rhythm.
#### 2.6 Eink-first by default
Every custom-styled bordered surface gets the `eink-bordered` class. Every primary action
gets `btn-primary` (which has dedicated eink rules). Don't rely on color or shadow alone
for hierarchy — eink screens have neither.
If you can't toggle Settings → Misc → Eink and still tell which button is the CTA, the
hierarchy is broken.
#### 2.7 Focus is visible but quiet
Keyboard focus needs a visible ring. `focus-visible:ring-2 focus-visible:ring-base-content/15`
is the canonical treatment for custom buttons. Loud `ring-primary` reserved for inputs
where the focus state IS the affordance.
#### 2.8 RTL: always use logical properties (REQUIRED)
Readest ships with RTL languages enabled. **Never use direction-bound Tailwind
utilities** when a logical equivalent exists — the visual edges flip in RTL,
the logical ones don't.
| Don't use | Use instead |
| ---------------------------------- | ---------------------------------- |
| `pl-*` / `pr-*` | `ps-*` (start) / `pe-*` (end) |
| `ml-*` / `mr-*` | `ms-*` / `me-*` |
| `text-left` / `text-right` | `text-start` / `text-end` |
| `border-l` / `border-r` | `border-s` / `border-e` |
| `rounded-l-*` / `rounded-r-*` | `rounded-s-*` / `rounded-e-*` |
| `left-*` / `right-*` (positioning) | `start-*` / `end-*` |
| `justify-start` / `justify-end` | (these ARE direction-aware) — keep |
The `flex-row` direction is automatically reversed in RTL by the browser, so
you usually don't need to do anything for `flex` / `gap`. Only **explicit
edges** (padding, margin, borders, radius, absolute positioning) need
logical properties.
**Quick scan when reviewing a diff:** grep for `\b(pl|pr|ml|mr|left-|right-|text-left|text-right|border-l|border-r|rounded-l|rounded-r)-` in changed files. Any hit that isn't a deliberate LTR-only
case (rare — usually only icon glyphs that have a fixed orientation) should
be flipped to the logical equivalent.
#### 2.9 Every panel and sub-page starts with title + description (REQUIRED)
Every settings panel and every sub-page must open with:
1. **A title** — the panel name. Style: `text-lg font-semibold tracking-tight`. In a
top-level panel this is an `<h2>`; in a sub-page this is the `parentLabel /
currentLabel` breadcrumb in `SubPageHeader` (which uses the same typography so the
word stays anchored visually as the user navigates in/out).
2. **A one-line description** — a short sentence under the title explaining what this
surface does or how it fits in the user's workflow. Style: `text-sm
text-base-content/70 leading-relaxed`. Skip it only when the surface is so trivial
the breadcrumb already says everything (rare — when in doubt, write one).
Why: orientation, visual rhythm, and Adwaita parity (`AdwPreferencesPage` always has
both). The same vertical opening across every surface makes the system feel cohesive
and gives users a predictable place to learn what a screen does.
**Canonical components.** The `<SubPageHeader>` primitive in
`src/components/settings/SubPageHeader.tsx` accepts a `description?: React.ReactNode`
prop that renders the description in the canonical style — sub-pages should pass it
there rather than rolling their own `<p>` below the header. Top-level panels currently
inline the title + description; if a third or fourth panel needs the same pattern,
extract a `<PanelHeader>` primitive following the same shape.
**Examples.**
```tsx
// Sub-page (Integrations → OPDS Catalogs)
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('OPDS Catalogs')}
description={_('Browse and download books from online catalogs')}
onBack={() => setSubPage(null)}
/>
// Top-level panel (Integrations panel root)
<div className='w-full'>
<h2 className='mb-1.5 text-lg font-semibold tracking-tight'>{_('Integrations')}</h2>
<p className='text-base-content/70 text-sm leading-relaxed'>
{_('Connect Readest to external services for sync, highlights, and catalogs.')}
</p>
</div>
```
---
### 3. Surface hierarchy
Three named tiers, mapped onto daisyui tokens. Use these terms in conversation and code
comments even though the classes are still daisyui-native.
| Tier | Token | Role | Example |
| ---------- | ------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------- |
| **Window** | `bg-base-200` | The outermost backdrop. Modal scrims, dialog content area, scroll containers. | `<Dialog>` body |
| **View** | `bg-base-100/60` or `bg-base-200/40` | Mid-tier surface inside a window. Tip boxes, secondary panels. | The "提示 / Tips" callout in CustomDictionaries |
| **Card** | `bg-base-100` | Top-tier content surface. Boxed lists, popovers, modal-box. | The dictionaries list card |
Border treatment:
- **Window** has no border (it IS the boundary).
- **View** uses no border or `border-base-200/60` for very soft delineation.
- **Card** uses `border border-base-200`. In e-ink, `eink-bordered` flips it to 1px
`border-base-content`.
Corner radius:
- **Card / View**: `rounded-lg` (8px) — Readest's house radius. Adwaita uses 9px; 8px is
close enough and matches Tailwind's scale.
- **Modal / Sheet**: `modal-box` default (~1rem / 16px) — bigger surfaces get bigger radii.
- **Pills / Chips**: `rounded-full`.
- **Inputs / small buttons**: `rounded-md` (6px) or `rounded-lg` (8px).
#### Surface continuity rule
When a control extends a card (an "add row" affordance, a footer button bar attached to a
list), it inherits the card's surface treatment: same `bg-base-100`, same
`border-base-200`, same `rounded-lg`. It is the card grown by one row.
---
### 4. Action vocabulary
Six archetypes. Pick by **role**, not by **appearance**.
#### 4.1 Accent CTA
The primary, accent-colored button. **One per surface, max.** Submit on a form, "Open
Book", "Sign In".
```tsx
className = 'btn btn-primary';
```
Eink: `btn-primary` has dedicated rules (inverts to base-content bg + base-100 text) so it
stays distinct from secondary actions on monochrome screens.
#### 4.2 Suggested
A non-accent-but-emphasized action. Used when there are multiple equally-weighted actions
and one is the recommended path. Adwaita's "suggested-action" CSS class.
```tsx
className = 'btn btn-neutral';
```
Rare. Most surfaces don't need this tier.
#### 4.3 Flat
The default secondary button. Sits on a view or card surface, no border, hover lifts to
`base-200`. The bulk of buttons should be flat.
```tsx
className="btn btn-ghost"
// or for a custom surface treatment:
className={clsx(
'rounded-lg px-4 py-2 text-sm font-medium',
'hover:bg-base-200 transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15',
)}
```
#### 4.4 Pill / Circular ghost
Compact icon-only buttons in header bars and toolbars. Always `rounded-full`,
`btn-circle` or hand-rolled circular ghost.
```tsx
className = 'btn btn-ghost btn-circle h-8 min-h-8 w-8 p-0';
```
The window controls in `SettingsDialog.tsx` (search, menu, close) use this archetype.
#### 4.5 Destructive
Delete, remove, irreversible. Adwaita uses `destructive-action`. Readest uses red
sparingly — usually only the icon, not the whole button.
```tsx
// Icon-only delete X in delete mode:
className = 'btn btn-ghost btn-sm shrink-0 px-1';
// with <IoMdCloseCircleOutline className="text-error h-4 w-4" />
```
For destructive **dialogs** (confirmation modals), the confirm button can be `btn-error`,
but only in the modal — never on the main surface.
#### 4.6 ListExtension
A Readest-named archetype for "add another row to the list above" affordances. The two
buttons at the bottom of `CustomDictionaries.tsx` are the canonical example.
Anatomy:
- Surface matches the parent card (`border border-base-200 bg-base-100 rounded-lg`)
- Height ~h-11
- Centered: small icon chip + label
- Icon chip: `bg-base-200 text-base-content/60 rounded-full h-5 w-5`
- Hover: border deepens to `base-300`, bg lightens to `bg-base-200/60`, icon chip inverts
to `bg-base-content text-base-100`
- `eink-bordered` on the button itself
```tsx
<button
type='button'
onClick={handleAdd}
className={clsx(
'eink-bordered group flex h-11 items-center justify-center gap-2.5',
'border-base-200 bg-base-100 rounded-lg border px-4',
'text-base-content text-sm font-medium',
'transition-colors duration-150',
'hover:border-base-300 hover:bg-base-200/60',
'active:bg-base-200/80',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
)}
>
<span
className={clsx(
'flex h-5 w-5 items-center justify-center rounded-full',
'bg-base-200 text-base-content/60',
'transition-colors duration-150',
'group-hover:bg-base-content group-hover:text-base-100',
)}
>
<MdAdd className='h-3.5 w-3.5' />
</span>
<span className='line-clamp-1'>{label}</span>
</button>
```
Use this for: "Import Dictionary", "Add Web Search", "Add Custom Theme", any "+ add new
to this list" pattern. **Do not** use `btn-outline btn-primary` for these.
---
### 5. Boxed list anatomy
The settings UI is built on boxed lists. One pattern, used everywhere.
#### Container
Use the `<BoxedList>` primitive at `src/components/settings/primitives/BoxedList.tsx`
rather than inlining the chassis classes:
```tsx
<BoxedList title={_('Reading Sync')} data-setting-id='settings.section.id'>
{/* rows */}
</BoxedList>
```
The primitive renders:
```tsx
<div className='card eink-bordered border-base-200 bg-base-100 border'>
<div className='divide-base-200 divide-y'>{children}</div>
</div>
```
- `card` for the radius
- `border border-base-200` for the boundary (eink upgrades this automatically)
- `eink-bordered` for the e-ink-mode contrast border
- `divide-base-200 divide-y` for inter-row separators
> **No `overflow-hidden` on the card.** Children may host popovers (color
> pickers, dropdowns, tooltips) that need to escape the card bounds. The
> `divide-y` rules sit between rows and don't touch the card's rounded
> corners, so omitting overflow-clip is visually safe AND keeps embedded
> popovers from getting clipped.
#### Row anatomy
Three slots, in order, always:
```
┌─────────────────────────────────────────────────────────────────┐
│ [prefix] Title text [suffix slots] │
│ [ ] Subtitle text (optional) [ ][ ]│
└─────────────────────────────────────────────────────────────────┘
```
| Slot | Contents |
| ------------ | ------------------------------------------------------------------------------------------------- |
| **Prefix** | Drag handle, leading icon, avatar, status dot, or empty. |
| **Title** | Primary label. `font-medium`. Truncates with `truncate`. |
| **Subtitle** | Optional secondary line. `text-sm text-base-content/70`. Used for warnings, descriptions, status. |
| **Suffix** | Badge, switch, button, chevron, value, or any combination. End-aligned. |
Canonical example: `SortableRow` in `src/components/settings/CustomDictionaries.tsx`. The
drag handle is the prefix, the dict name is the title, the warning reason is the
subtitle, and the badge + toggle + edit/delete buttons stack as suffixes.
#### Row variants
- **ActionRow** — title + suffix is a single button or chevron. Tap anywhere navigates.
- **SwitchRow** — title + suffix is a toggle. Tap anywhere toggles.
- **ComboRow** — title + suffix is a dropdown/select.
- **ExpanderRow** — chevron suffix; tap expands to reveal nested rows.
These names come from libadwaita and apply 1:1 to Readest's lists. Use the names in code
comments and PR descriptions.
#### Spacing
- Row vertical padding: `py-2` (8px) for compact lists, `py-3` (12px) for breathing room.
- Row horizontal padding: `px-3` (12px) or `px-4` (16px). Stay consistent within a list.
- Slot gap: `gap-2` (8px) between prefix/title/suffix elements.
#### Disabled rows
Disabled rows fade the title to `text-base-content/60` and disable the suffix control. The
row itself stays at full opacity — only the **content** dims, not the row.
#### Toggle size
| Daisyui class | Use case |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `toggle` (default, h-5 / ~20px) | **Settings panel boxed-list rows**`<SettingsSwitchRow>` uses this. Visible weight matches the 56px `min-h-14` row. |
| `toggle-sm` (h-4 / ~16px) | Inline secondary switches in tighter contexts — e.g. dictionary list rows in `CustomDictionaries`. |
| `toggle-xs` (h-3 / ~12px) | Compact metadata toggles inside cards — e.g. OPDS catalog "Auto-download". |
The `<SettingsSwitchRow>` primitive bakes in the default `toggle`. **Don't override
to `toggle-sm` inside boxed-list rows** — it looks orphaned in the row's vertical
breathing room. Use the smaller sizes only when the row itself is shorter than 56px.
#### Typography inherits from `.settings-content`
The Settings dialog (and any settings-style sheet/popup) wraps its content
in `.settings-content`, which is defined in `src/styles/globals.css` as:
```css
.dropdown-content,
.settings-content {
font-size: 14px; /* desktop */
}
@media (max-width: 768px) {
.dropdown-content,
.settings-content {
font-size: 16px; /* mobile bump — high-DPI phones need bigger body text */
}
}
```
**Don't hardcode `text-sm` on row labels, NavigationRow titles, or panel
descriptions** — that locks the text to 14px on every viewport and kills
the mobile bump. Instead:
- **Primary labels** (SettingsRow label, NavigationRow title, SubPageHeader
description, ad-hoc row labels in panels and integration forms): no
font-size class — inherits 14/16 from the wrapper. Use `<SettingLabel>`
rather than inlining a `<span>`; it adds `font-medium` for cased scripts
and drops the weight for caseless scripts (CJK / Arabic / Hebrew / Indic
/ Thai / Tibetan), since those bold poorly at body size and `font-medium`
on Han / Hangul / Devanagari renders as uneven stroke-thickening across
system fonts.
- **Secondary text** (SettingsRow description, NavigationRow status, Tips
body, BoxedList description): use `text-[0.85em]` so it stays
proportional (≈12px desktop, ≈13.6px mobile).
- **Form controls** (`<input>`, `<select>`): browsers don't inherit
font-size onto form elements, so add the `settings-content` class
_directly on the element_ to re-apply the 14/16 cascade. The legacy
NumberInput already does this — match its pattern.
- **Section headers** (`BoxedList` uppercase title): use `text-[0.85em]
font-semibold uppercase tracking-wider`. The em-relative size keeps it
proportional with the `.settings-content` cascade. **Caseless-script
exception:** when `isCaselessUILang()` is true, bump to `text-[1em]`.
The `uppercase` rule is a no-op in scripts without case (CJK, Arabic,
Hebrew, Devanagari/Bengali/Tamil/Sinhala, Thai, Tibetan), so the size
has to carry the emphasis those scripts can't pick up from casing. The
helper lives in `src/utils/misc.ts`; the underlying `isCaselessLang`
predicate lists every covered language code in `src/utils/lang.ts`.
Why this matters: Tailwind's `text-xs` / `text-sm` are rem-based — they
ignore the parent's `font-size` because rem is rooted at the document.
The `.settings-content` cascade is in `px`, so any child that picks a
Tailwind size literally tunes itself to the desktop default and never
grows on mobile. iOS and Android have small physical screens but high
DPI, so the mobile bump is what makes the text legible at typical reading
distance.
#### Uniform row height
Settings rows in a boxed list MUST all be the same visual height. Use
`min-h-14 items-center` (56px) on each row container — toggle, select, and
input rows then center their controls vertically inside identical boxes.
**Don't use `py-3`** — content-driven padding produces uneven heights
because toggles, selects (`h-9`), and inputs (`h-9`) have different
intrinsic sizes.
```tsx
// ✓ Right — no text-sm; label inherits .settings-content (14/16)
<label className='flex min-h-14 items-center justify-between px-4'>
<span className='font-medium'>{_('Sync Enabled')}</span>
<input type='checkbox' className='toggle' ... />
</label>
// ✗ Wrong — toggle row will be 48px, select rows 60px
<label className='flex items-center justify-between px-4 py-3'>...</label>
// ✗ Wrong — text-sm hardcodes 14px even on mobile (kills the bump)
<span className='text-sm font-medium'>{_('Sync Enabled')}</span>
```
#### Controls inside a boxed list have no chrome
When a control sits inside a bordered card, it shouldn't carry its own
border or fill. The card supplies the visual boundary; the control just
sits on the row.
- **Selects:** drop `select-bordered` and `eink-bordered`. Add
`!bg-transparent !bg-none !appearance-none` to suppress daisyui's
background chevron and native arrow. Render a real `<MdArrowDropDown>`
icon at the cell's trailing edge for the affordance — see "End-aligned
values" below.
- **Inputs:** drop `input-bordered` and `eink-bordered`. Add `!bg-transparent`
with `hover:!bg-base-200/60 focus:!bg-base-200/60` so the field still
signals interactability. Use `text-end` and `!pe-0` so the value sits
flush against the row's trailing edge.
- **Toggles:** untouched — they're already chromeless.
This is the iOS Settings / Adwaita PreferencesGroup convention: list
chrome belongs to the container, not its children.
#### End-aligned values + chevron alignment
The selected value of a select/input MUST end-align (`text-end`). The
**visible right edge** of every row's value (toggle, chevron icon, input
text) MUST land at the same X — the row's trailing padding.
The trap: daisyui's select renders its chevron via background-image at
`calc(100% - 1rem) center`, which floats the glyph 16px _inside_ the
select's right edge. So if the toggle in row 1 ends at the row's `pe-4`
edge, the chevron in row 2 ends 16px before that — visibly misaligned.
**Fix:** suppress daisyui's bg-image chevron and render an explicit icon at
the cell's trailing edge. The select's own daisyui focus chrome (outline +
box-shadow + ring) is suppressed; **no focus ring** on controls inside the
boxed list — focus state is signaled by a subtle wrapper bg-shift instead
(hover and focus-within both lift to `bg-base-200/60`). Rings would compete
with the card's own border and double-stack with adjacent rows.
```tsx
<div className='hover:bg-base-200/60 focus-within:bg-base-200/60 flex max-w-[60%] items-center rounded-md'>
<select className='select h-9 min-w-0 cursor-pointer !appearance-none truncate !border-0 !bg-transparent !bg-none !pe-1 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0'>
{/* options */}
</select>
<MdArrowDropDown
aria-hidden='true'
className='text-base-content/55 pointer-events-none h-5 w-5 flex-shrink-0'
/>
</div>
```
> **Why so many `!` overrides?** daisyui's `.select` and `.input` apply
> `border-width: 1px` + `border-color` (transparent at rest, `var(--bc)` on
> focus), plus `outline`, `box-shadow`, and `ring` chrome on focus. To make
> the control truly chromeless inside a boxed list, you need to kill all
> four properties. Missing any of them — especially `border-0` — leaves a
> visible focus border leaking through.
The `<MdArrowDropDown>` icon's trailing edge now lives at the same X as the
toggle's trailing edge in adjacent rows, because both are flush with the
row's `pe-4` padding.
For inputs, no wrapper is needed — the input is one element, so put the
hover/focus bg directly on it. Suppress daisyui's own focus chrome the
same way:
```tsx
<input className='input hover:!bg-base-200/60 focus:!bg-base-200/60 h-9 max-w-[60%] rounded-md !border-0 !bg-transparent !pe-0 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0' />
```
> **Why no ring here when §2.7 says "focus needs a visible ring"?** §2.7 is
> for standalone custom buttons (Submit, Cancel, ListExtension, etc.). In a
> boxed list, the row already provides strong visual containment via the
> card border + dividers, and stacking a per-control ring inside that
> creates double chrome. The bg-shift IS the focus indicator — keyboard
> users still get clear feedback; the surface stays calm.
---
### 6. Header bars, dialogs, popups, sheets
#### Header bar
The dialog/page header. Adwaita's AdwHeaderBar.
- **4856px tall** (`h-12` to `h-14`).
- **Center-aligned title** in `font-semibold text-base`.
- **Leading slot**: back chevron (mobile) or empty (desktop).
- **Trailing slot**: window controls — search (pill ghost), menu (pill ghost),
close (pill ghost circle with `bg-base-300/65`).
- No bottom border; rely on tab/divider that follows.
`SettingsDialog.tsx`'s mobile header is the canonical example. The desktop header is
slightly different — tabs sit in the same row as window controls, no center title — but
it's the same archetype adapted for screen real estate.
#### Dialog (modal)
```tsx
<Dialog
isOpen={...}
onClose={...}
boxClassName="sm:min-w-[520px] overflow-hidden"
header={<HeaderBar />}
>
{/* content */}
</Dialog>
```
- `modal-box` provides the radius, max-width, and shadow (auto-removed in eink).
- Width ~520px on desktop, full-width on mobile.
- Bottom sheets on mobile via `snapHeight` prop.
- Backdrop: `sm:!bg-black/50` (or `/20` when nested over a darker surface).
#### Popup (popover)
For dictionary lookups, annotation editors, and other anchored overlays. Uses the
`Popup` component with a triangle pointer.
- **Width**: clamp to fit content; ~320420px typical.
- **Surface**: `bg-base-100`, `rounded-lg`, soft shadow (eink removes shadow).
- **Triangle**: pointer toward the anchor; eink has special triangle classes.
- **Padding**: `p-3` to `p-4` for content.
#### Sheet (mobile bottom)
Reserved for mobile contextual menus and full-screen secondary panels. Uses the dialog's
`snapHeight` prop. Adwaita doesn't have a native sheet but Readest's mobile pattern is
the closest analog.
- Always full-width.
- Top corners rounded; bottom corners flat (it's anchored to the bottom).
- Drag handle at top (the small horizontal pill) is mandatory if the sheet supports
swipe-to-dismiss.
---
### 7. Motion + a11y
#### Motion
- Default duration: **150ms** for color transitions.
- Default easing: browser default (`ease`) or `ease-out`. Never `ease-in`.
- Longer transitions (300ms+) only for layout changes (sheet snap, panel slide).
- **Never** use `transform` for hover unless the transform IS the message
(chevron rotation, drag-handle drag visualization). E-ink doesn't render mid-transitions
cleanly and Adwaita's identity is calm.
```tsx
// Good — hover:bg-base-200 with transition-colors
className = 'transition-colors duration-150 hover:bg-base-200';
// Bad — scale on hover
className = 'transition-transform hover:scale-105';
```
Existing exceptions: `.window-button` in globals.css uses `hover:scale-105`. That's
legacy; new code shouldn't follow it.
#### Reduced motion
Reduced-motion preference is honored via the `no-transitions` class
(`globals.css:624`). Layout-changing transitions should respect
`prefers-reduced-motion: reduce` either via this class or `motion-safe:` Tailwind
prefixes.
#### Focus
- Every focusable element must have a visible focus indicator.
- Custom buttons:
`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15`.
- Inputs: rely on daisyui's input focus ring; inputs with custom styling use
`focus:ring-2 focus:ring-primary/40`.
- Don't use `outline-none` without `focus-visible:` replacement.
#### Hit targets
- **Minimum**: 32px (the size of `btn-sm`).
- **Recommended**: 40px (`btn`) on touch surfaces.
- **Mobile**: 44px+ for taps that aren't fail-safe (delete, navigate-away).
- The `touch-target` class in globals.css extends a small visual control's hit area to
44px without changing its rendered size — use it on icon-sized buttons in mobile UIs.
#### Color contrast
- Body text on background: WCAG AA (4.5:1) minimum.
- Large text: WCAG AA Large (3:1) minimum.
- Interactive text on hover state: still passes contrast on the new background.
- Theme palette is generated from `(bg, fg, primary)`; the tinycolor pipeline keeps
contrast within range, but custom themes can break this — Settings → Color flags
low-contrast custom themes.
#### Keyboard
- Tab order matches visual order. If you use `flex-row-reverse` for visual layout,
consider `tabIndex` to fix order.
- Modal focus trap: `<Dialog>` handles this.
- Esc to dismiss: `<Dialog>` and `<Popup>` handle this.
- Arrow keys for grouped controls (radio-like tab strips, sortable lists). dnd-kit's
`KeyboardSensor` is wired for sortable lists.
---
### 8. E-ink overlay (cross-cutting)
E-ink mode is toggled by `[data-eink='true']` on the document. It applies a global
override layer in `src/styles/globals.css:484-622` that:
- Removes all `box-shadow`.
- Forces `text-base-content`, `text-blue-*`, `text-red-*`, `text-neutral-content` to a
single foreground color.
- Inverts `btn-primary` and `btn-outline` to base-content bg + base-100 text.
- Adds 1px contrast borders to `.eink-bordered`, `.modal-box`, `.menu-container`,
`.popup-container`, `.alert`, `.opds-navigation .card`, `.booknote-item`,
`.bookitem-main`.
What this means for new components:
| Surface type | Required class | Why |
| ------------------------------------ | ----------------------- | ------------------------------------------------------------- |
| Custom bordered button or input | `eink-bordered` | Gets the 1px contrast border in eink |
| Primary CTA | `btn-primary` | Picks up the inverted treatment |
| Cancel / secondary action | `btn-ghost` (no border) | Reads as "outlined" only after pairing with the CTA |
| Card / panel using `border-base-200` | `eink-bordered` | Otherwise the soft border vanishes in eink |
| Modal / Popup | (auto) | `modal-box` and `.popup-container` are handled in globals.css |
Verification checklist before shipping a new UI:
- [ ] Toggle Settings → Misc → Eink mode and re-test every screen.
- [ ] Every container that has a soft border (`border-base-200`) still has visible
delineation.
- [ ] Every CTA is distinguishable from its neighbors (cancel, secondary).
- [ ] No hover transforms make the UI feel jumpy.
- [ ] Text is fully opaque (no `text-base-content/60` content; eink can't render the
reduced opacity well).
#### What's NOT compatible with e-ink
- Drop shadows for hierarchy (use borders).
- Color-only state changes (use border weight or fill swap).
- Hover scale / translate (they look broken on slow refresh).
- Animations longer than ~200ms (visible refresh artifacts).
---
### 9. Cross-platform grace notes
Readest ships on **macOS, Windows, Linux, iOS, Android, web**. Adwaita is desktop-GNOME-
native; we adapt where the host OS has strong conventions, but never at the cost of
identity.
#### iOS
- Slightly larger corner radii feel native (`rounded-xl` on dialogs, `rounded-lg` on
cards).
- Safe area insets are mandatory for top + bottom anchored elements (see
`docs/safe-area-insets.md`).
- Avoid Material Design ripple effects.
- Sheet-style modals (bottom-anchored) match iOS conventions and are preferred over
centered dialogs on phone-sized screens.
#### Android
- Material 3 conventions that conflict with Adwaita (FABs, elevation shadows, ripple
inks): **don't** copy them. Readest's identity is Adwaita; the user is reading on
Android, not in Android.
- Touch targets bumped to 48px for primary actions (Material's recommended target).
- Back-gesture-aware UIs: ensure swipe-from-edge doesn't conflict with horizontal swipe
controls.
#### Linux
- Native Adwaita territory. Readest can match host theme for window chrome (Tauri
decorations) but should keep its own internal palette for the reading surface — book
themes (sepia, gruvbox, etc.) are user choices, not OS choices.
#### macOS / Windows
- Window controls (close/minimize/maximize) are platform-native via Tauri.
- Title bar height matches platform convention; internal layout follows Readest's
Adwaita palette.
#### Web
- No safe-area insets needed.
- Keyboard shortcuts are doubled with command-palette discoverability (Cmd/Ctrl+K).
- Browser-native focus rings: respected, augmented with `focus-visible:ring-*`.
#### E-ink readers (Android-based, custom firmware)
- Detected via the eink mode toggle (Settings → Misc).
- All rules in §8 apply.
- This is a **first-class** target, not a fallback.
---
### 10. Anti-patterns
Things that LOOK fine in isolation but break the system. Each one has a real source diff
or commit reference.
#### 10.1 Loud outlined CTAs for non-primary actions
```tsx
// Anti-pattern (was in CustomDictionaries.tsx, fixed Nov 2026):
<button className='btn btn-outline btn-primary gap-2 normal-case [--animation-btn:0s]'>
<MdAdd className='h-5 w-5' />
Import Dictionary
</button>
// Correct: ListExtension archetype (see §4.6)
```
Why it broke: the buttons read as primary CTAs but are list extensions. They competed
with the active settings tab indicator and pulled the eye from the list itself.
#### 10.2 Recoloring the whole button on hover
```tsx
// Anti-pattern:
<button className="text-base-content/70 hover:text-base-content hover:bg-primary/10">
// Correct: keep the label color steady, hover via bg shift on the surface
<button className="text-base-content hover:bg-base-200 transition-colors">
```
Why: principle 2.4 (localize the hover signal). Whole-button color shifts feel decorative.
#### 10.3 Transform-based hover
```tsx
// Anti-pattern:
<button className="hover:scale-105 transition-transform">
// Correct: color/border-based hover
<button className="hover:bg-base-200 hover:border-base-300 transition-colors">
```
Why: breaks under e-ink (§2.5), feels jumpy under Adwaita's calm rhythm.
#### 10.4 Soft borders without `eink-bordered`
```tsx
// Anti-pattern:
<div className="border border-base-200 bg-base-100 rounded-lg p-4">
...
</div>
// Correct:
<div className="eink-bordered border border-base-200 bg-base-100 rounded-lg p-4">
...
</div>
```
Why: in e-ink mode, `base-200` borders disappear into the background. `eink-bordered`
flips the border to `base-content` so the boundary stays visible.
Exception: containers that **don't** need a visible boundary in eink (e.g., a
`bg-base-100` surface that's already against `bg-base-200`) can skip `eink-bordered`.
The class is opt-in for "this surface needs a border to read correctly".
#### 10.5 Reduced-opacity text in e-ink
```tsx
// Anti-pattern (in eink):
<span className="text-base-content/50">Optional metadata</span>
// Correct (still readable in eink):
<span className="text-base-content text-xs">Optional metadata</span>
// Or use semantic muting that the eink overlay handles:
<span className="text-neutral-content">Optional metadata</span>
```
Why: e-ink's reduced color depth turns `/50` opacity into illegible mush. Use size or
weight for hierarchy on muted secondary text.
#### 10.6 Daisyui `btn` defaults without intent
```tsx
// Anti-pattern: just reaching for `btn` with no role:
<button className="btn">Click me</button>
// Correct: pick an archetype from §4.
<button className="btn btn-ghost">Cancel</button> // Flat
<button className="btn btn-primary">Save</button> // Accent CTA
```
Why: daisyui's `btn` default isn't tuned for any specific role. Pick from the action
vocabulary so the button signals its weight in the surface hierarchy.
#### 10.7 Ad-hoc surface tokens
```tsx
// Anti-pattern:
<div className="bg-white border-gray-200">
// Correct:
<div className="bg-base-100 border-base-200">
```
Why: hard-coded colors don't theme. Readest has 11 themes plus user-defined custom themes.
Always use the daisyui semantic tokens.
#### 10.8 Mixing `btn` sizes within a surface
```tsx
// Anti-pattern:
<header>
<button className="btn btn-sm">Search</button>
<button className="btn btn-md">Settings</button>
<button className="btn btn-xs">Close</button>
</header>
// Correct: one size per surface
<header>
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Search</button>
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Settings</button>
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Close</button>
</header>
```
Why: visual rhythm. Mixed sizes feel like the surface is unfinished.
---
### 11. Quick reference
When designing a new surface, walk this checklist:
1. **What's the surface tier?** Window / View / Card. (§3)
2. **What's the corner radius?** Match the tier. (§3)
3. **Is there a primary action?** If yes, ONE accent CTA. If no, all flats. (§4.1, §4.3)
4. **Are there list extensions?** Use the ListExtension archetype, not `btn-outline btn-primary`. (§4.6)
5. **Is it a list?** Use the BoxedList chassis with ActionRow / SwitchRow / ComboRow / ExpanderRow rows. (§5)
6. **Does it need `eink-bordered`?** If it has a soft border that must stay visible in
eink mode, yes. (§8)
7. **Is the hover signal localized?** One focal element changes, not the whole control. (§2.4)
8. **Is motion color-only?** No transforms unless the transform IS the message. (§2.5)
9. **Is focus visible?** `focus-visible:ring-2 focus-visible:ring-base-content/15` on
custom buttons. (§7)
10. **Will it work on the smallest theme + e-ink?** Toggle Sepia + Eink, retest.
---
### 12. Glossary
- **Adwaita / libadwaita**: GNOME's design system and widget toolkit. Source of Readest's
visual lineage.
- **AdwActionRow / AdwSwitchRow / AdwComboRow / AdwExpanderRow**: libadwaita's row
primitives. Readest mirrors these conceptually with custom React components.
- **AdwBoxedList**: libadwaita's named container for grouped action rows.
- **AdwBanner**: top-of-window inline alert (persistent).
- **AdwToast**: bottom slide-in transient alert.
- **Window / View / Card**: surface tiers (§3).
- **ListExtension**: Readest-named archetype for "+ add new row" buttons (§4.6).
- **eink-bordered**: utility class in `globals.css` that gives a surface its e-ink-mode
contrast border. Opt-in.
- **Pill ghost**: circular icon button, `btn-ghost btn-circle`.
---
### 13. Maintenance
This doc is the **source of truth** for new design decisions. When the system grows:
- New archetypes get a numbered subsection in §4 or §5.
- New anti-patterns get added to §10 with a real source reference.
- Updates to existing principles require a brief why-changed note in the relevant section.
Cross-references that must stay in sync:
- `CLAUDE.md` E-ink mode section → §8 of this doc.
- `docs/safe-area-insets.md` → §9 (cross-platform).
- `src/styles/globals.css` `[data-eink]` rules → §8.
- `src/styles/themes.ts` Palette type → §3 token table.
If you change a rule here, search for the cross-reference and update both.
@@ -476,7 +476,6 @@
"Remove from Cloud Only": "إزالة من السحابة فقط",
"Remove from Device Only": "إزالة من الجهاز فقط",
"Disconnected": "غير متصل",
"KOReader Sync Settings": "إعدادات مزامنة KOReader",
"Sync Strategy": "استراتيجية المزامنة",
"Ask on conflict": "اسأل عند حدوث تعارض",
"Always use latest": "استخدام الأحدث دائمًا",
@@ -494,10 +493,7 @@
"Connect": "الاتصال",
"KOReader Sync": "مزامنة KOReader",
"Sync Conflict": "تعارض المزامنة",
"Sync reading progress from \"{{deviceName}}\"?": "مزامنة تقدم القراءة من \"{{deviceName}}\"؟",
"another device": "جهاز آخر",
"Local Progress": "التقدم المحلي",
"Remote Progress": "التقدم البعيد",
"Page {{page}} of {{total}} ({{percentage}}%)": "الصفحة {{page}} من {{total}} ({{percentage}}%)",
"Current position": "الموقع الحالي",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "تقريبًا الصفحة {{page}} من {{total}} ({{percentage}}%)",
@@ -650,7 +646,6 @@
"Strikethrough": "يتوسطه خط",
"Squiggly": "متعرج",
"Outline": "مخطط",
"Save Current Color": "حفظ اللون الحالي",
"Quick Colors": "ألوان سريعة",
"Highlighter": "محدد النص",
"Save Book Cover": "حفظ غلاف الكتاب",
@@ -1082,16 +1077,12 @@
"Invalid Readwise access token": "رمز وصول Readwise غير صالح",
"Disconnected from Readwise": "تم قطع الاتصال بـ Readwise",
"Never": "أبداً",
"Readwise Settings": "إعدادات Readwise",
"Connected to Readwise": "متصل بـ Readwise",
"Last synced: {{time}}": "آخر مزامنة: {{time}}",
"Sync Enabled": "تم تمكين المزامنة",
"Disconnect": "قطع الاتصال",
"Connect your Readwise account to sync highlights.": "قم بتوصيل حساب Readwise الخاص بك لمزامنة التمييزات.",
"Get your access token at": "احصل على رمز الوصول الخاص بك من",
"Access Token": "رمز الوصول",
"Paste your Readwise access token": "الصق رمز وصول Readwise الخاص بك",
"Config": "تكوين",
"Readwise Sync": "مزامنة Readwise",
"Push Highlights": "إرسال التمييزات",
"Highlights synced to Readwise": "تمت مزامنة التمييزات مع Readwise",
@@ -1201,15 +1192,12 @@
"Drag to seek": "اسحب للتقديم",
"Punctuation Delay": "تأخير علامات الترقيم",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "يرجى التأكيد على أن اتصال OPDS هذا سيتم توجيهه عبر خوادم Readest على تطبيق الويب قبل المتابعة.",
"Custom Headers": "رؤوس مخصصة",
"Custom Headers (optional)": "رؤوس مخصصة (اختياري)",
"Add one header per line using \"Header-Name: value\".": "أضف رأسًا واحدًا لكل سطر باستخدام \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "أفهم أن اتصال OPDS هذا سيتم توجيهه عبر خوادم Readest على تطبيق الويب. إذا لم أثق بـ Readest فيما يتعلق ببيانات الاعتماد أو الرؤوس هذه، يجب أن أستخدم التطبيق الأصلي بدلاً من ذلك.",
"Unable to connect to Hardcover. Please check your network connection.": "تعذر الاتصال بـ Hardcover. يرجى التحقق من اتصال الشبكة.",
"Invalid Hardcover API token": "رمز API الخاص بـ Hardcover غير صالح",
"Disconnected from Hardcover": "تم قطع الاتصال بـ Hardcover",
"Hardcover Settings": "إعدادات Hardcover",
"Connected to Hardcover": "متصل بـ Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "اربط حسابك على Hardcover لمزامنة تقدم القراءة والملاحظات.",
"Get your API token from hardcover.app → Settings → API.": "احصل على رمز API من hardcover.app ← الإعدادات ← API.",
"API Token": "رمز API",
@@ -1494,5 +1482,38 @@
"Credentials": "بيانات الاعتماد",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "الرموز وأسماء المستخدمين وكلمات المرور لـ OPDS و KOReader و Hardcover و Readwise. عند التعطيل، تبقى بيانات الاعتماد على هذا الجهاز فقط ولا تُرفع أبدًا.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "تُشفَّر الحقول الحساسة المتزامنة قبل الرفع. عيّن عبارة مرور الآن أو لاحقًا عند الحاجة إلى التشفير.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "محفوظة في هذا الحساب. سيُطلب منك إدخالها عند فك تشفير بيانات الاعتماد."
"Saved to this account. You will be prompted for it when decrypting credentials.": "محفوظة في هذا الحساب. سيُطلب منك إدخالها عند فك تشفير بيانات الاعتماد.",
"Reading progress on this device differs from \"{{deviceName}}\".": "يختلف تقدم القراءة على هذا الجهاز عن «{{deviceName}}».",
"This device": "هذا الجهاز",
"Set up KOReader Sync…": "إعداد مزامنة KOReader…",
"Set up Readwise…": "إعداد Readwise…",
"Set up Hardcover…": "إعداد Hardcover…",
"Red": "أحمر",
"Yellow": "أصفر",
"Green": "أخضر",
"Blue": "أزرق",
"Violet": "بنفسجي",
"Edit color": "تعديل اللون",
"Add custom color": "إضافة لون مخصص",
"Add label": "إضافة تسمية",
"Choose color": "اختيار لون",
"Connected to Hardcover. Last synced {{time}}.": "متصل بـ Hardcover. آخر مزامنة {{time}}.",
"Integrations": "التكاملات",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "متصل بـ Readwise. آخر مزامنة {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "تصفح وتنزيل الكتب من الفهارس عبر الإنترنت.",
"Connected as {{user}}": "متصل باسم {{user}}",
"Not connected": "غير متصل",
"{{count}} catalog_zero": "لا توجد فهارس",
"{{count}} catalog_one": "فهرس واحد",
"{{count}} catalog_two": "فهرسان",
"{{count}} catalog_few": "{{count}} فهارس",
"{{count}} catalog_many": "{{count}} فهرسًا",
"{{count}} catalog_other": "{{count}} فهرس",
"No catalogs": "لا توجد فهارس",
"Connect Readest to external services for sync, highlights, and catalogs.": "اربط Readest بخدمات خارجية للمزامنة والإبرازات والفهارس.",
"Reading Sync": "مزامنة القراءة",
"Content Sources": "مصادر المحتوى",
"Scroll tabs": "تمرير علامات التبويب"
}
@@ -155,13 +155,9 @@
"Small": "ছোট",
"Large": "বড়",
"Sync Conflict": "সিঙ্ক দ্বন্দ্ব",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" থেকে পড়ার অগ্রগতি সিঙ্ক করবেন?",
"another device": "অন্য ডিভাইস",
"Local Progress": "স্থানীয় অগ্রগতি",
"Remote Progress": "দূরবর্তী অগ্রগতি",
"Failed to connect": "সংযোগ ব্যর্থ",
"Disconnected": "সংযোগ বিচ্ছিন্ন",
"KOReader Sync Settings": "KOReader সিঙ্ক সেটিংস",
"Sync as {{userDisplayName}}": "{{userDisplayName}} হিসেবে সিঙ্ক",
"Sync Server Connected": "সিঙ্ক সার্ভার সংযুক্ত",
"Sync Strategy": "সিঙ্ক কৌশল",
@@ -634,7 +630,6 @@
"Strikethrough": "কাটা লেখা",
"Squiggly": "ঢেউখেলানো দাগ",
"Outline": "বাহিরের রেখা",
"Save Current Color": "বর্তমান রঙ সংরক্ষণ করুন",
"Quick Colors": "দ্রুত রংসমূহ",
"Highlighter": "হাইলাইটার",
"Save Book Cover": "বইয়ের মলাট সংরক্ষণ করুন",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "অকার্যকর Readwise অ্যাক্সেস টোকেন",
"Disconnected from Readwise": "Readwise থেকে সংযোগ বিচ্ছিন্ন হয়েছে",
"Never": "কখনো না",
"Readwise Settings": "Readwise সেটিংস",
"Connected to Readwise": "Readwise এর সাথে সংযুক্ত",
"Last synced: {{time}}": "শেষ সিঙ্ক করা হয়েছে: {{time}}",
"Sync Enabled": "সিঙ্কিং সক্ষম",
"Disconnect": "সংযোগ বিচ্ছিন্ন করুন",
"Connect your Readwise account to sync highlights.": "হাইলাইটগুলি সিঙ্ক করার জন্য আপনার Readwise অ্যাকাউন্টটি সংযোগ করুন।",
"Get your access token at": "আপনার অ্যাক্সেস টোকেনটি এখানে পাবেন",
"Access Token": "অ্যাক্সেস টোকেন",
"Paste your Readwise access token": "আপনার Readwise অ্যাক্সেস টোকেনটি পেস্ট করুন",
"Config": "কনফিগ",
"Readwise Sync": "Readwise সিঙ্ক",
"Push Highlights": "হাইলাইট পাঠান",
"Highlights synced to Readwise": "হাইলাইটগুলি Readwise এ সিঙ্ক করা হয়েছে",
@@ -1149,15 +1140,12 @@
"Drag to seek": "খুঁজতে টানুন",
"Punctuation Delay": "বিরাম চিহ্ন বিলম্ব",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "অনুগ্রহ করে নিশ্চিত করুন যে এই OPDS সংযোগটি ওয়েব অ্যাপে Readest সার্ভারের মাধ্যমে প্রক্সি করা হবে।",
"Custom Headers": "কাস্টম হেডার",
"Custom Headers (optional)": "কাস্টম হেডার (ঐচ্ছিক)",
"Add one header per line using \"Header-Name: value\".": "প্রতি লাইনে একটি হেডার যোগ করুন \"Header-Name: value\" ব্যবহার করে।",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "আমি বুঝতে পারছি যে এই OPDS সংযোগটি ওয়েব অ্যাপে Readest সার্ভারের মাধ্যমে প্রক্সি করা হবে। আমি যদি এই শংসাপত্র বা হেডারগুলির সাথে Readest-কে বিশ্বাস না করি, তবে আমার পরিবর্তে নেটিভ অ্যাপটি ব্যবহার করা উচিত।",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover-এ সংযোগ করা যাচ্ছে না। অনুগ্রহ করে আপনার নেটওয়ার্ক সংযোগ পরীক্ষা করুন।",
"Invalid Hardcover API token": "অবৈধ Hardcover API টোকেন",
"Disconnected from Hardcover": "Hardcover থেকে সংযোগ বিচ্ছিন্ন",
"Hardcover Settings": "Hardcover সেটিংস",
"Connected to Hardcover": "Hardcover-এ সংযুক্ত",
"Connect your Hardcover account to sync reading progress and notes.": "পড়ার অগ্রগতি এবং নোট সিঙ্ক করতে আপনার Hardcover অ্যাকাউন্ট সংযুক্ত করুন।",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → সেটিংস → API থেকে আপনার API টোকেন পান।",
"API Token": "API টোকেন",
@@ -1398,5 +1386,34 @@
"Credentials": "ক্রেডেনশিয়াল",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS, KOReader, Hardcover এবং Readwise-এর জন্য টোকেন, ব্যবহারকারীর নাম এবং পাসওয়ার্ড। নিষ্ক্রিয় থাকলে ক্রেডেনশিয়ালগুলি কেবল এই ডিভাইসেই থাকে এবং কখনই আপলোড হয় না।",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "সংবেদনশীল সিঙ্ক করা ক্ষেত্রগুলি আপলোডের আগে এনক্রিপ্ট করা হয়। এনক্রিপশনের প্রয়োজন হলে এখনই অথবা পরে একটি পাসফ্রেজ সেট করুন।",
"Saved to this account. You will be prompted for it when decrypting credentials.": "এই অ্যাকাউন্টে সংরক্ষিত। ক্রেডেনশিয়াল ডিক্রিপ্ট করার সময় আপনাকে এটি দিতে বলা হবে।"
"Saved to this account. You will be prompted for it when decrypting credentials.": "এই অ্যাকাউন্টে সংরক্ষিত। ক্রেডেনশিয়াল ডিক্রিপ্ট করার সময় আপনাকে এটি দিতে বলা হবে।",
"Reading progress on this device differs from \"{{deviceName}}\".": "এই ডিভাইসে পড়ার অগ্রগতি \"{{deviceName}}\" থেকে আলাদা।",
"This device": "এই ডিভাইস",
"Set up KOReader Sync…": "KOReader সিঙ্ক সেট আপ করুন…",
"Set up Readwise…": "Readwise সেট আপ করুন…",
"Set up Hardcover…": "Hardcover সেট আপ করুন…",
"Red": "লাল",
"Yellow": "হলুদ",
"Green": "সবুজ",
"Blue": "নীল",
"Violet": "বেগুনি",
"Edit color": "রঙ সম্পাদনা করুন",
"Add custom color": "কাস্টম রঙ যোগ করুন",
"Add label": "লেবেল যোগ করুন",
"Choose color": "রঙ নির্বাচন করুন",
"Connected to Hardcover. Last synced {{time}}.": "Hardcover-এর সাথে সংযুক্ত। সর্বশেষ সিঙ্ক {{time}}।",
"Integrations": "ইন্টিগ্রেশন",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwise-এর সাথে সংযুক্ত। সর্বশেষ সিঙ্ক {{time}}।",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "অনলাইন ক্যাটালগ থেকে বই ব্রাউজ এবং ডাউনলোড করুন।",
"Connected as {{user}}": "{{user}} হিসাবে সংযুক্ত",
"Not connected": "সংযুক্ত নয়",
"{{count}} catalog_one": "{{count}}টি ক্যাটালগ",
"{{count}} catalog_other": "{{count}}টি ক্যাটালগ",
"No catalogs": "কোনো ক্যাটালগ নেই",
"Connect Readest to external services for sync, highlights, and catalogs.": "সিঙ্ক, হাইলাইট এবং ক্যাটালগের জন্য Readest-কে বাহ্যিক পরিষেবার সাথে সংযুক্ত করুন।",
"Reading Sync": "পড়ার সিঙ্ক",
"Content Sources": "কন্টেন্ট উৎস",
"Scroll tabs": "ট্যাব স্ক্রোল করুন"
}
@@ -456,7 +456,6 @@
"Remove from Device Only": "སྒྲིག་ཆས་ཐོག་ནས་ཕྱིར་འཐེན།",
"Failed to connect": "འབྲེལ་བ་བྱས་མ་ཐུབ།",
"Disconnected": "འབྲེལ་བ་བྱས་མ་ཐུབ།",
"KOReader Sync Settings": "KOReader སྤྲི་དོན་གཞི་འདེམས་པ།",
"Sync as {{userDisplayName}}": "ཁྱེད་ཀྱིས {{userDisplayName}} སྤྲི་དོན་གཞི་འདེམས་པ།",
"Sync Server Connected": "སྤྲི་དོན་སང་བཞིན་འདེམས་པ།",
"Sync Strategy": "སྤྲི་དོན་འབྱུང་ཁུངས།",
@@ -476,10 +475,7 @@
"Connect": "འབྲེལ་བ་བྱས་པ།",
"KOReader Sync": "KOReader སྤྲི་དོན་འདེམས་པ།",
"Sync Conflict": "སྤྲི་དོན་འབྱུང་ཁུངས།",
"Sync reading progress from \"{{deviceName}}\"?": "ཁྱེད་ཀྱིས \"{{deviceName}}\" སྤྲི་དོན་འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"another device": "གཞན་ཡིག་སྣེ།",
"Local Progress": "ཀུན་འབྱུང་ཁུངས།",
"Remote Progress": "བརྒྱབ་འབྱུང་ཁུངས།",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Page {{page}} of {{total}} ({{percentage}}%)": "ཤོག་ {{page}} དང་ {{total}} ({{percentage}}%)",
"Current position": "ད་ལྟའི་གནས་ས།",
@@ -630,7 +626,6 @@
"Strikethrough": "འཐེན་ཐིག",
"Squiggly": "འཁྱོག་ཐིག",
"Outline": "མཐའ་ཐིག",
"Save Current Color": "མིག་སྔའི་ཚོན་གཏན་འཁེལ་བྱེད།",
"Quick Colors": "མྱུར་ཚོན།",
"Highlighter": "མདོག་ཚད་ཀྱི་བསྡུར་བ།",
"Save Book Cover": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
@@ -1022,16 +1017,12 @@
"Invalid Readwise access token": "Readwise འཛུལ་སྤྱོད་ལག་ཁྱེར་ནོར་འདུག",
"Disconnected from Readwise": "Readwise ནས་མཐུད་ལམ་བཅད་ཟིན།",
"Never": "ནམ་ཡང་མིན།",
"Readwise Settings": "Readwise སྒྲིག་བཀོད།",
"Connected to Readwise": "Readwise ལ་མཐུད་ཟིན།",
"Last synced: {{time}}": "མཐའ་མའི་མཉམ་བྱུང་དུས་ཚོད། {{time}}",
"Sync Enabled": "མཉམ་བྱུང་ནུས་པ་སྤར་ཟིན།",
"Disconnect": "མཐུད་ལམ་གཅོད་པ།",
"Connect your Readwise account to sync highlights.": "ཁྱེད་ཀྱི་ Readwise རྩིས་ཐོ་མཐུད་ནས་བཀོད་མཆན་མཉམ་བྱུང་གནང་རོགས།",
"Get your access token at": "འཛུལ་སྤྱོད་ལག་ཁྱེར་འདི་ནས་ལེན་རོགས།",
"Access Token": "འཛུལ་སྤྱོད་ལག་ཁྱེེར།",
"Paste your Readwise access token": "Readwise འཛུལ་སྤྱོད་ལག་ཁྱེར་འདིར་སྦྱོར་རོགས།",
"Config": "སྒྲིག་བཀོད།",
"Readwise Sync": "Readwise མཉམ་བྱུང་།",
"Push Highlights": "བཀོད་མཆན་སྐྱེལ་བ།",
"Highlights synced to Readwise": "བཀོད་མཆན་ Readwise ལ་མཉམ་བྱུང་བྱས་ཟིན།",
@@ -1136,15 +1127,12 @@
"Drag to seek": "འཚོལ་བར་འདྲུད།",
"Punctuation Delay": "ཚེག་ཤད་ཕྱིར་འགྱངས།",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "མུ་མཐུད་མ་བྱས་གོང་ OPDS འབྲེལ་མཐུད་འདི་དྲ་རྒྱའི་ཉེར་སྤྱོད་ཐོག་ Readest ཞབས་ཞུ་བ་བརྒྱུད་ནས་བསྐུར་རྒྱུ་ཡིན་པ་གཏན་འཁེལ་བྱོས།",
"Custom Headers": "སྲོལ་སྒྲིག་མགོ་བརྗེ",
"Custom Headers (optional)": "སྲོལ་སྒྲིག་མགོ་བརྗེ (གདམ་གའི)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" བེད་སྤྱོད་བྱས་ནས་ཕྲེང་རེ་རེའི་ནང་མགོ་བརྗེ་གཅིག་སྣོན།",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "OPDS འབྲེལ་མཐུད་འདི་དྲ་རྒྱའི་ཉེར་སྤྱོད་ཐོག་ Readest ཞབས་ཞུ་བ་བརྒྱུད་ནས་བསྐུར་རྒྱུ་ཡིན་པ་ངས་ཧ་གོ། ང་ Readest ལ་ངེས་འཛིན་འམ་མགོ་བརྗེ་འདི་དག་ཡིད་ཆེས་མེད་ན། ང་ས་གནས་ཉེར་སྤྱོད་བེད་སྤྱོད་བྱ་དགོས།",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover ལ་འབྲེལ་མཐུད་བྱེད་ཐུབ་མ་སོང་། ཁྱེད་ཀྱི་དྲ་རྒྱའི་འབྲེལ་མཐུད་ལ་ཞིབ་བཤེར་གནང་།",
"Invalid Hardcover API token": "Hardcover API ཐོབ་ཐང་མ་ཆིག་སྒྲིལ་མིན།",
"Disconnected from Hardcover": "Hardcover ནས་འབྲེལ་མཐུད་ཆད་སོང་།",
"Hardcover Settings": "Hardcover སྒྲིག་འགོད",
"Connected to Hardcover": "Hardcover ལ་འབྲེལ་མཐུད་བྱས་ཟིན།",
"Connect your Hardcover account to sync reading progress and notes.": "ཀློག་འགྲོས་དང་མཆན་ཟླ་སྒྲིག་བྱེད་པར་ཁྱེད་ཀྱི Hardcover ཐོ་ཁུངས་འབྲེལ་མཐུད་བྱོས།",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → སྒྲིག་འགོད → API ནས་ཁྱེད་ཀྱི API ཐོབ་ཐང་ལེན།",
"API Token": "API ཐོབ་ཐང་།",
@@ -1374,5 +1362,33 @@
"Credentials": "ངོས་སྦྱོར་ཆ་འཕྲིན།",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS, KOReader, Hardcover, དང་ Readwise བཅས་ཀྱི་ཐོ་ཀེན་དང་སྤྱོད་མི་མིང་དང་གསང་གྲངས། སྤོ་འཛུགས་མེད་པའི་སྐབས། ངོས་སྦྱོར་ཆ་འཕྲིན་ཡིག་ཆས་འདི་ཁོ་ནར་གནས་པ་དང་ནམ་ཡང་སྤོ་གཏོང་མི་བྱེད།",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "གཙོ་གནད་ཆགས་པའི་མཉམ་སྡེབ་ཐོ་ཁོངས་ཡིག་ཆ་སྤོ་གཏོང་མ་བྱས་སྔོན་ལ་གསང་སྦས་སུ་འགྱུར་བར་བཟོ། གསང་སྦས་དགོས་སྐབས་ད་ལྟ་འམ་ཕྱིས་སུ་གསང་ཚིག་ཕྲེང་གཅིག་སྒྲིག་འཛུགས་གནང་རོགས།",
"Saved to this account. You will be prompted for it when decrypting credentials.": "རྩིས་ཐོ་འདིར་ཉར་ཚགས་བྱས་ཡོད། ངོས་སྦྱོར་ཆ་འཕྲིན་ལ་གསང་སྦས་འགྲོལ་སྐབས་ཁྱེད་ལ་འདྲི་སློང་གནང་འགྱུར།"
"Saved to this account. You will be prompted for it when decrypting credentials.": "རྩིས་ཐོ་འདིར་ཉར་ཚགས་བྱས་ཡོད། ངོས་སྦྱོར་ཆ་འཕྲིན་ལ་གསང་སྦས་འགྲོལ་སྐབས་ཁྱེད་ལ་འདྲི་སློང་གནང་འགྱུར།",
"Reading progress on this device differs from \"{{deviceName}}\".": "སྒྲིག་ཆས་འདིའི་ཀློག་འགྲོས་ནི་\"{{deviceName}}\"དང་མི་འདྲ།",
"This device": "སྒྲིག་ཆས་འདི།",
"Set up KOReader Sync…": "KOReader མཉམ་སྒྲིག་སྒྲིག་འགོད…",
"Set up Readwise…": "Readwise སྒྲིག་འགོད…",
"Set up Hardcover…": "Hardcover སྒྲིག་འགོད…",
"Red": "དམར་པོ།",
"Yellow": "སེར་པོ།",
"Green": "ལྗང་ཁུ།",
"Blue": "སྔོན་པོ།",
"Violet": "མུ་མེན།",
"Edit color": "ཁ་དོག་རྩོམ་སྒྲིག",
"Add custom color": "རང་འདེམས་ཁ་དོག་སྣོན་པ།",
"Add label": "བྱང་བུ་སྣོན་པ།",
"Choose color": "ཁ་དོག་འདེམས་པ།",
"Connected to Hardcover. Last synced {{time}}.": "Hardcover ལ་མཐུད་ཟིན། མཐའ་མའི་མཉམ་སྒྲིག་ {{time}}།",
"Integrations": "མཉམ་སྦྲེལ།",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwise ལ་མཐུད་ཟིན། མཐའ་མའི་མཉམ་སྒྲིག་ {{time}}།",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "དྲ་ཐོག་དཀར་ཆག་ནས་དེབ་བལྟ་ཞིབ་དང་ཕབ་ལེན་བྱེད་པ།",
"Connected as {{user}}": "{{user}} ཡི་མིང་འོག་མཐུད་ཟིན།",
"Not connected": "མ་མཐུད།",
"{{count}} catalog_other": "དཀར་ཆག་ {{count}}",
"No catalogs": "དཀར་ཆག་མེད།",
"Connect Readest to external services for sync, highlights, and catalogs.": "Readest ཕྱི་ཕྱོགས་ཞབས་ཞུ་ལ་སྦྲེལ་ནས་མཉམ་སྒྲིག་དང་གསལ་སྟོན། དཀར་ཆག་སོགས་སྤྱོད།",
"Reading Sync": "ཀློག་པའི་མཉམ་སྒྲིག",
"Content Sources": "ནང་དོན་འབྱུང་ཁུངས།",
"Scroll tabs": "ཤོག་བྱང་འགུལ་སྐྱོད།"
}
@@ -460,7 +460,6 @@
"Remove from Cloud Only": "Nur aus der Cloud entfernen",
"Remove from Device Only": "Nur vom Gerät entfernen",
"Disconnected": "Getrennt",
"KOReader Sync Settings": "KOReader Sync-Einstellungen",
"Sync Strategy": "Sync-Strategie",
"Ask on conflict": "Bei Konflikten fragen",
"Always use latest": "Immer die neueste Version verwenden",
@@ -478,10 +477,7 @@
"Connect": "Verbinden",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Sync-Konflikt",
"Sync reading progress from \"{{deviceName}}\"?": "Lesefortschritt von \"{{deviceName}}\" synchronisieren?",
"another device": "ein anderes Gerät",
"Local Progress": "Lokaler Fortschritt",
"Remote Progress": "Entfernter Fortschritt",
"Page {{page}} of {{total}} ({{percentage}}%)": "Seite {{page}} von {{total}} ({{percentage}}%)",
"Current position": "Aktuelle Position",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Ungefähr Seite {{page}} von {{total}} ({{percentage}}%)",
@@ -634,7 +630,6 @@
"Strikethrough": "Durchgestrichen",
"Squiggly": "Wellig",
"Outline": "Umriss",
"Save Current Color": "Aktuelle Farbe speichern",
"Quick Colors": "Schnellfarben",
"Highlighter": "Textmarker",
"Save Book Cover": "Buchcover speichern",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "Ungültiges Readwise-Zugriffstoken",
"Disconnected from Readwise": "Von Readwise getrennt",
"Never": "Nie",
"Readwise Settings": "Readwise-Einstellungen",
"Connected to Readwise": "Mit Readwise verbunden",
"Last synced: {{time}}": "Zuletzt synchronisiert: {{time}}",
"Sync Enabled": "Synchronisierung aktiviert",
"Disconnect": "Trennen",
"Connect your Readwise account to sync highlights.": "Verbinden Sie Ihr Readwise-Konto, um Highlights zu synchronisieren.",
"Get your access token at": "Holen Sie sich Ihr Zugriffstoken unter",
"Access Token": "Zugriffstoken",
"Paste your Readwise access token": "Fügen Sie Ihr Readwise-Zugriffstoken ein",
"Config": "Konfiguration",
"Readwise Sync": "Readwise-Synchronisierung",
"Push Highlights": "Highlights übertragen",
"Highlights synced to Readwise": "Highlights mit Readwise synchronisiert",
@@ -1149,15 +1140,12 @@
"Drag to seek": "Ziehen zum Suchen",
"Punctuation Delay": "Satzzeichen-Verzögerung",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Bitte bestätigen Sie, dass diese OPDS-Verbindung über Readest-Server in der Web-App weitergeleitet wird, bevor Sie fortfahren.",
"Custom Headers": "Benutzerdefinierte Header",
"Custom Headers (optional)": "Benutzerdefinierte Header (optional)",
"Add one header per line using \"Header-Name: value\".": "Fügen Sie pro Zeile einen Header im Format \"Header-Name: value\" hinzu.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Ich verstehe, dass diese OPDS-Verbindung über Readest-Server in der Web-App weitergeleitet wird. Wenn ich Readest meine Anmeldedaten oder Header nicht anvertraue, sollte ich stattdessen die native App verwenden.",
"Unable to connect to Hardcover. Please check your network connection.": "Verbindung zu Hardcover nicht möglich. Bitte überprüfen Sie Ihre Netzwerkverbindung.",
"Invalid Hardcover API token": "Ungültiges Hardcover-API-Token",
"Disconnected from Hardcover": "Von Hardcover getrennt",
"Hardcover Settings": "Hardcover-Einstellungen",
"Connected to Hardcover": "Mit Hardcover verbunden",
"Connect your Hardcover account to sync reading progress and notes.": "Verbinden Sie Ihr Hardcover-Konto, um Lesefortschritt und Notizen zu synchronisieren.",
"Get your API token from hardcover.app → Settings → API.": "Holen Sie Ihr API-Token von hardcover.app → Einstellungen → API.",
"API Token": "API-Token",
@@ -1398,5 +1386,34 @@
"Credentials": "Anmeldedaten",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Tokens, Benutzernamen und Passwörter für OPDS, KOReader, Hardcover und Readwise. Wenn deaktiviert, bleiben die Anmeldedaten nur auf diesem Gerät und werden nie hochgeladen.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Sensible synchronisierte Felder werden vor dem Hochladen verschlüsselt. Lege jetzt oder später eine Passphrase fest, wenn die Verschlüsselung benötigt wird.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "In diesem Konto gespeichert. Du wirst beim Entschlüsseln von Anmeldedaten danach gefragt."
"Saved to this account. You will be prompted for it when decrypting credentials.": "In diesem Konto gespeichert. Du wirst beim Entschlüsseln von Anmeldedaten danach gefragt.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Der Lesefortschritt auf diesem Gerät weicht von „{{deviceName}}“ ab.",
"This device": "Dieses Gerät",
"Set up KOReader Sync…": "KOReader-Sync einrichten…",
"Set up Readwise…": "Readwise einrichten…",
"Set up Hardcover…": "Hardcover einrichten…",
"Red": "Rot",
"Yellow": "Gelb",
"Green": "Grün",
"Blue": "Blau",
"Violet": "Violett",
"Edit color": "Farbe bearbeiten",
"Add custom color": "Benutzerdefinierte Farbe hinzufügen",
"Add label": "Beschriftung hinzufügen",
"Choose color": "Farbe auswählen",
"Connected to Hardcover. Last synced {{time}}.": "Mit Hardcover verbunden. Zuletzt synchronisiert {{time}}.",
"Integrations": "Integrationen",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Mit Readwise verbunden. Zuletzt synchronisiert {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Bücher aus Online-Katalogen durchsuchen und herunterladen.",
"Connected as {{user}}": "Verbunden als {{user}}",
"Not connected": "Nicht verbunden",
"{{count}} catalog_one": "{{count}} Katalog",
"{{count}} catalog_other": "{{count}} Kataloge",
"No catalogs": "Keine Kataloge",
"Connect Readest to external services for sync, highlights, and catalogs.": "Verbinden Sie Readest mit externen Diensten für Synchronisierung, Markierungen und Kataloge.",
"Reading Sync": "Lese-Synchronisierung",
"Content Sources": "Inhaltsquellen",
"Scroll tabs": "Tabs scrollen"
}
@@ -460,7 +460,6 @@
"Remove from Cloud Only": "Αφαίρεση μόνο από το Cloud",
"Remove from Device Only": "Αφαίρεση μόνο από το Device",
"Disconnected": "Αποσυνδεδεμένο",
"KOReader Sync Settings": "Ρυθμίσεις συγχρονισμού KOReader",
"Sync Strategy": "Στρατηγική συγχρονισμού",
"Ask on conflict": "Ρώτησε σε περίπτωση σύγκρουσης",
"Always use latest": "Χρησιμοποίησε πάντα την τελευταία έκδοση",
@@ -478,10 +477,7 @@
"Connect": "Σύνδεση",
"KOReader Sync": "Συγχρονισμός KOReader",
"Sync Conflict": "Σύγκρουση συγχρονισμού",
"Sync reading progress from \"{{deviceName}}\"?": "Συγχρονίστε την πρόοδο ανάγνωσης από το \"{{deviceName}}\";",
"another device": "άλλη συσκευή",
"Local Progress": "Τοπική πρόοδος",
"Remote Progress": "Απομακρυσμένη πρόοδος",
"Page {{page}} of {{total}} ({{percentage}}%)": "Σελίδα {{page}} από {{total}} ({{percentage}}%)",
"Current position": "Τρέχουσα θέση",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Περίπου σελίδα {{page}} από {{total}} ({{percentage}}%)",
@@ -634,7 +630,6 @@
"Strikethrough": "Διαγραμμένο",
"Squiggly": "Κυματιστό",
"Outline": "Περίγραμμα",
"Save Current Color": "Αποθήκευση τρέχουσας χρώματος",
"Quick Colors": "Γρήγορα χρώματα",
"Highlighter": "Υπογραμμιστής",
"Save Book Cover": "Αποθήκευση εξωφύλλου βιβλίου",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "Μη έγκυρο διακριτικό πρόσβασης Readwise",
"Disconnected from Readwise": "Αποσυνδέθηκε από το Readwise",
"Never": "Ποτέ",
"Readwise Settings": "Ρυθμίσεις Readwise",
"Connected to Readwise": "Συνδέθηκε στο Readwise",
"Last synced: {{time}}": "Τελευταίος συγχρονισμός: {{time}}",
"Sync Enabled": "Ο συγχρονισμός ενεργοποιήθηκε",
"Disconnect": "Αποσύνδεση",
"Connect your Readwise account to sync highlights.": "Συνδέστε τον λογαριασμό σας Readwise για να συγχρονίσετε τις επισημάνσεις.",
"Get your access token at": "Αποκτήστε το διακριτικό πρόσβασής σας στο",
"Access Token": "Διακριτικό πρόσβασης",
"Paste your Readwise access token": "Επικολλήστε το διακριτικό πρόσβασης Readwise",
"Config": "Ρύθμιση παραμέτρων",
"Readwise Sync": "Συγχρονισμός Readwise",
"Push Highlights": "Προώθηση επισημάνσεων",
"Highlights synced to Readwise": "Οι επισημάνσεις συγχρονίστηκαν στο Readwise",
@@ -1149,15 +1140,12 @@
"Drag to seek": "Σύρετε για αναζήτηση",
"Punctuation Delay": "Καθυστέρηση στίξης",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Επιβεβαιώστε ότι αυτή η σύνδεση OPDS θα δρομολογηθεί μέσω των διακομιστών Readest στην εφαρμογή ιστού πριν συνεχίσετε.",
"Custom Headers": "Προσαρμοσμένες κεφαλίδες",
"Custom Headers (optional)": "Προσαρμοσμένες κεφαλίδες (προαιρετικό)",
"Add one header per line using \"Header-Name: value\".": "Προσθέστε μία κεφαλίδα ανά γραμμή χρησιμοποιώντας \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Κατανοώ ότι αυτή η σύνδεση OPDS θα δρομολογηθεί μέσω των διακομιστών Readest στην εφαρμογή ιστού. Αν δεν εμπιστεύομαι τo Readest με αυτά τα διαπιστευτήρια ή τις κεφαλίδες, θα πρέπει να χρησιμοποιήσω την εγγενή εφαρμογή.",
"Unable to connect to Hardcover. Please check your network connection.": "Αδυναμία σύνδεσης στο Hardcover. Ελέγξτε τη σύνδεση δικτύου σας.",
"Invalid Hardcover API token": "Μη έγκυρο Hardcover API token",
"Disconnected from Hardcover": "Αποσυνδέθηκε από το Hardcover",
"Hardcover Settings": "Ρυθμίσεις Hardcover",
"Connected to Hardcover": "Συνδεδεμένο στο Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Συνδέστε τον λογαριασμό σας Hardcover για συγχρονισμό προόδου ανάγνωσης και σημειώσεων.",
"Get your API token from hardcover.app → Settings → API.": "Λάβετε το API token σας από hardcover.app → Ρυθμίσεις → API.",
"API Token": "API Token",
@@ -1398,5 +1386,34 @@
"Credentials": "Διαπιστευτήρια",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Διακριτικά, ονόματα χρήστη και κωδικοί πρόσβασης για OPDS, KOReader, Hardcover και Readwise. Όταν είναι απενεργοποιημένο, τα διαπιστευτήρια παραμένουν μόνο σε αυτή τη συσκευή και δεν αποστέλλονται ποτέ.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Τα ευαίσθητα συγχρονισμένα πεδία κρυπτογραφούνται πριν τη μεταφόρτωση. Ορίστε μια φράση πρόσβασης τώρα ή αργότερα, όταν χρειαστεί η κρυπτογράφηση.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Αποθηκεύτηκε σε αυτόν τον λογαριασμό. Θα σας ζητηθεί όταν χρειαστεί να αποκρυπτογραφηθούν διαπιστευτήρια."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Αποθηκεύτηκε σε αυτόν τον λογαριασμό. Θα σας ζητηθεί όταν χρειαστεί να αποκρυπτογραφηθούν διαπιστευτήρια.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Η πρόοδος ανάγνωσης σε αυτή τη συσκευή διαφέρει από το «{{deviceName}}».",
"This device": "Αυτή η συσκευή",
"Set up KOReader Sync…": "Ρύθμιση συγχρονισμού KOReader…",
"Set up Readwise…": "Ρύθμιση Readwise…",
"Set up Hardcover…": "Ρύθμιση Hardcover…",
"Red": "Κόκκινο",
"Yellow": "Κίτρινο",
"Green": "Πράσινο",
"Blue": "Μπλε",
"Violet": "Βιολετί",
"Edit color": "Επεξεργασία χρώματος",
"Add custom color": "Προσθήκη προσαρμοσμένου χρώματος",
"Add label": "Προσθήκη ετικέτας",
"Choose color": "Επιλογή χρώματος",
"Connected to Hardcover. Last synced {{time}}.": "Συνδέθηκε στο Hardcover. Τελευταίος συγχρονισμός {{time}}.",
"Integrations": "Ενσωματώσεις",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Συνδέθηκε στο Readwise. Τελευταίος συγχρονισμός {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Περιηγηθείτε και κατεβάστε βιβλία από διαδικτυακούς καταλόγους.",
"Connected as {{user}}": "Συνδέθηκε ως {{user}}",
"Not connected": "Μη συνδεδεμένο",
"{{count}} catalog_one": "{{count}} κατάλογος",
"{{count}} catalog_other": "{{count}} κατάλογοι",
"No catalogs": "Δεν υπάρχουν κατάλογοι",
"Connect Readest to external services for sync, highlights, and catalogs.": "Συνδέστε το Readest με εξωτερικές υπηρεσίες για συγχρονισμό, επισημάνσεις και καταλόγους.",
"Reading Sync": "Συγχρονισμός ανάγνωσης",
"Content Sources": "Πηγές περιεχομένου",
"Scroll tabs": "Κύλιση καρτελών"
}
@@ -40,6 +40,8 @@
"Failed to sync {{count}} OPDS catalog(s)_other": "Failed to sync {{count}} OPDS catalogs",
"Imported {{count}} dictionary_one": "Imported {{count}} dictionary",
"Imported {{count}} dictionary_other": "Imported {{count}} dictionaries",
"{{count}} catalog_one": "{{count}} catalog",
"{{count}} catalog_other": "{{count}} catalogs",
"Replaced {{count}} existing dictionary_one": "Replaced {{count}} existing dictionary",
"Replaced {{count}} existing dictionary_other": "Replaced {{count}} existing dictionaries",
"{{count}} books refreshed_one": "{{count}} book refreshed",
@@ -69,7 +69,6 @@
"Published": "Publicado",
"Publisher": "Editorial",
"KOReader Sync": "Sincronización con KOReader",
"KOReader Sync Settings": "Ajustes de Sincronización con KOReader",
"Your Username": "Tu nombre de usuario",
"Connect to your KOReader Sync server.": "Conéctate a tu servidor de KOReader Sync.",
"Server URL": "URL del Servidor",
@@ -88,9 +87,6 @@
"Device Name": "Nombre del dispositivo",
"Reading Progress Synced": "Progreso de lectura sincronizado",
"Sync Conflict": "Conflicto de sincronización",
"Sync reading progress from \"{{deviceName}}\"?": "¿Quieres sincronizar el progreso de lectura desde el dispositivo \"{{deviceName}}\"?",
"Local Progress": "Progreso local",
"Remote Progress": "Progreso remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Página {{page}} de {{total}} ({{percentage}}%)",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Aproximadamente página {{page}} de {{total}} ({{percentage}}%)",
"Current position": "Posición actual",
@@ -638,7 +634,6 @@
"Strikethrough": "Tachado",
"Squiggly": "Ondulado",
"Outline": "Contorno",
"Save Current Color": "Guardar Color Actual",
"Quick Colors": "Colores Rápidos",
"Highlighter": "Resaltador",
"Save Book Cover": "Guardar Portada del Libro",
@@ -1046,16 +1041,12 @@
"Invalid Readwise access token": "Token de acceso a Readwise no válido",
"Disconnected from Readwise": "Desconectado de Readwise",
"Never": "Nunca",
"Readwise Settings": "Ajustes de Readwise",
"Connected to Readwise": "Conectado a Readwise",
"Last synced: {{time}}": "Última sincronización: {{time}}",
"Sync Enabled": "Sincronización habilitada",
"Disconnect": "Desconectar",
"Connect your Readwise account to sync highlights.": "Conecta tu cuenta de Readwise para sincronizar los resaltados.",
"Get your access token at": "Obtén tu token de acceso en",
"Access Token": "Token de acceso",
"Paste your Readwise access token": "Pega tu token de acceso de Readwise",
"Config": "Configuración",
"Readwise Sync": "Sincronización con Readwise",
"Push Highlights": "Enviar resaltados",
"Highlights synced to Readwise": "Resaltados sincronizados con Readwise",
@@ -1162,15 +1153,12 @@
"Drag to seek": "Arrastra para buscar",
"Punctuation Delay": "Retraso de puntuación",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Confirme que esta conexión OPDS se enrutará a través de los servidores de Readest en la aplicación web antes de continuar.",
"Custom Headers": "Encabezados personalizados",
"Custom Headers (optional)": "Encabezados personalizados (opcional)",
"Add one header per line using \"Header-Name: value\".": "Añada un encabezado por línea usando \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Entiendo que esta conexión OPDS se enrutará a través de los servidores de Readest en la aplicación web. Si no confío en Readest con estas credenciales o encabezados, debería usar la aplicación nativa.",
"Unable to connect to Hardcover. Please check your network connection.": "No se puede conectar a Hardcover. Compruebe su conexión de red.",
"Invalid Hardcover API token": "Token de API de Hardcover no válido",
"Disconnected from Hardcover": "Desconectado de Hardcover",
"Hardcover Settings": "Ajustes de Hardcover",
"Connected to Hardcover": "Conectado a Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Conecte su cuenta de Hardcover para sincronizar el progreso de lectura y las notas.",
"Get your API token from hardcover.app → Settings → API.": "Obtenga su token de API en hardcover.app → Ajustes → API.",
"API Token": "Token de API",
@@ -1422,5 +1410,35 @@
"Credentials": "Credenciales",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Tokens, nombres de usuario y contraseñas de OPDS, KOReader, Hardcover y Readwise. Si está desactivado, las credenciales permanecen solo en este dispositivo y nunca se cargan.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Los campos sincronizados sensibles se cifran antes de subirlos. Establece una frase de contraseña ahora o más tarde, cuando se necesite el cifrado.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Guardada en esta cuenta. Se te pedirá cuando se necesite descifrar credenciales."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Guardada en esta cuenta. Se te pedirá cuando se necesite descifrar credenciales.",
"Reading progress on this device differs from \"{{deviceName}}\".": "El progreso de lectura en este dispositivo difiere del de «{{deviceName}}».",
"This device": "Este dispositivo",
"Set up KOReader Sync…": "Configurar sincronización con KOReader…",
"Set up Readwise…": "Configurar Readwise…",
"Set up Hardcover…": "Configurar Hardcover…",
"Red": "Rojo",
"Yellow": "Amarillo",
"Green": "Verde",
"Blue": "Azul",
"Violet": "Violeta",
"Edit color": "Editar color",
"Add custom color": "Añadir color personalizado",
"Add label": "Añadir etiqueta",
"Choose color": "Elegir color",
"Connected to Hardcover. Last synced {{time}}.": "Conectado a Hardcover. Última sincronización {{time}}.",
"Integrations": "Integraciones",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Conectado a Readwise. Última sincronización {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Explora y descarga libros desde catálogos en línea.",
"Connected as {{user}}": "Conectado como {{user}}",
"Not connected": "No conectado",
"{{count}} catalog_one": "{{count}} catálogo",
"{{count}} catalog_many": "{{count}} catálogos",
"{{count}} catalog_other": "{{count}} catálogos",
"No catalogs": "Sin catálogos",
"Connect Readest to external services for sync, highlights, and catalogs.": "Conecta Readest con servicios externos para sincronización, resaltados y catálogos.",
"Reading Sync": "Sincronización de lectura",
"Content Sources": "Fuentes de contenido",
"Scroll tabs": "Desplazar pestañas"
}
@@ -460,7 +460,6 @@
"Remove from Cloud Only": "حذف فقط از فضای ابری",
"Remove from Device Only": "حذف فقط از دستگاه",
"Disconnected": "قطع اتصال",
"KOReader Sync Settings": "تنظیمات همگام‌سازی KOReader",
"Sync Strategy": "استراتژی همگام‌سازی",
"Ask on conflict": "پرسش هنگام تداخل",
"Always use latest": "همیشه از آخرین استفاده شود",
@@ -478,10 +477,7 @@
"Connect": "اتصال",
"KOReader Sync": "همگام‌سازی KOReader",
"Sync Conflict": "تداخل همگام‌سازی",
"Sync reading progress from \"{{deviceName}}\"?": "همگام‌سازی وضعیت مطالعه از «{{deviceName}}»؟",
"another device": "دستگاه دیگر",
"Local Progress": "وضعیت مطالعه محلی",
"Remote Progress": "وضعیت مطالعه از راه دور",
"Page {{page}} of {{total}} ({{percentage}}%)": "صفحه‌ی {{page}} از {{total}} ({{percentage}}%)",
"Current position": "موقعیت فعلی",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "تقریباً صفحه‌ی {{page}} از {{total}} ({{percentage}}%)",
@@ -634,7 +630,6 @@
"Strikethrough": "خط‌خورده",
"Squiggly": "خط‌موج‌دار",
"Outline": "خط‌کشی",
"Save Current Color": "ذخیره رنگ فعلی",
"Quick Colors": "رنگ‌های سریع",
"Highlighter": "ماژیک هایلایت",
"Save Book Cover": "ذخیره جلد کتاب",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "توکن دسترسی Readwise نامعتبر است",
"Disconnected from Readwise": "اتصال از Readwise قطع شد",
"Never": "هرگز",
"Readwise Settings": "تنظیمات Readwise",
"Connected to Readwise": "به Readwise متصل شد",
"Last synced: {{time}}": "آخرین همگام‌سازی: {{time}}",
"Sync Enabled": "همگام‌سازی فعال شد",
"Disconnect": "قطع اتصال",
"Connect your Readwise account to sync highlights.": "برای همگام‌سازی هایلایت‌ها، حساب Readwise خود را متصل کنید.",
"Get your access token at": "توکن دسترسی خود را از اینجا دریافت کنید:",
"Access Token": "توکن دسترسی",
"Paste your Readwise access token": "توکن دسترسی Readwise خود را جای‌گذاری کنید",
"Config": "پیکربندی",
"Readwise Sync": "همگام‌سازی Readwise",
"Push Highlights": "ارسال هایلایت‌ها",
"Highlights synced to Readwise": "هایلایت‌ها با Readwise همگام‌سازی شدند",
@@ -1149,15 +1140,12 @@
"Drag to seek": "بکشید برای جستجو",
"Punctuation Delay": "تأخیر نشانه‌گذاری",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "لطفاً تأیید کنید که این اتصال OPDS از طریق سرورهای Readest در برنامه وب پروکسی خواهد شد.",
"Custom Headers": "سرآیندهای سفارشی",
"Custom Headers (optional)": "سرآیندهای سفارشی (اختیاری)",
"Add one header per line using \"Header-Name: value\".": "در هر خط یک سرآیند با استفاده از \"Header-Name: value\" اضافه کنید.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "متوجه هستم که این اتصال OPDS از طریق سرورهای Readest در برنامه وب پروکسی خواهد شد. اگر به Readest برای این اعتبارنامه‌ها یا سرآیندها اعتماد ندارم، باید از برنامه بومی استفاده کنم.",
"Unable to connect to Hardcover. Please check your network connection.": "اتصال به Hardcover ممکن نیست. لطفاً اتصال شبکه خود را بررسی کنید.",
"Invalid Hardcover API token": "توکن API نامعتبر Hardcover",
"Disconnected from Hardcover": "قطع اتصال از Hardcover",
"Hardcover Settings": "تنظیمات Hardcover",
"Connected to Hardcover": "متصل به Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "حساب Hardcover خود را برای همگام‌سازی پیشرفت مطالعه و یادداشت‌ها متصل کنید.",
"Get your API token from hardcover.app → Settings → API.": "توکن API خود را از hardcover.app ← تنظیمات ← API دریافت کنید.",
"API Token": "توکن API",
@@ -1398,5 +1386,34 @@
"Credentials": "اعتبارنامه‌ها",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "توکن‌ها، نام‌های کاربری و رمزهای عبور OPDS، KOReader، Hardcover و Readwise. هنگام غیرفعال بودن، اعتبارنامه‌ها فقط روی این دستگاه باقی می‌مانند و هرگز بارگذاری نمی‌شوند.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "فیلدهای حساسِ همگام‌سازی‌شده پیش از بارگذاری رمزگذاری می‌شوند. اکنون یا بعداً، هنگامی که به رمزگذاری نیاز شد، یک عبارت عبور تنظیم کنید.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "در این حساب ذخیره شد. هنگام رمزگشایی اعتبارنامه‌ها از شما خواسته می‌شود."
"Saved to this account. You will be prompted for it when decrypting credentials.": "در این حساب ذخیره شد. هنگام رمزگشایی اعتبارنامه‌ها از شما خواسته می‌شود.",
"Reading progress on this device differs from \"{{deviceName}}\".": "پیشرفت مطالعه در این دستگاه با «{{deviceName}}» تفاوت دارد.",
"This device": "این دستگاه",
"Set up KOReader Sync…": "تنظیم همگام‌سازی KOReader…",
"Set up Readwise…": "تنظیم Readwise…",
"Set up Hardcover…": "تنظیم Hardcover…",
"Red": "قرمز",
"Yellow": "زرد",
"Green": "سبز",
"Blue": "آبی",
"Violet": "بنفش",
"Edit color": "ویرایش رنگ",
"Add custom color": "افزودن رنگ سفارشی",
"Add label": "افزودن برچسب",
"Choose color": "انتخاب رنگ",
"Connected to Hardcover. Last synced {{time}}.": "متصل به Hardcover. آخرین همگام‌سازی {{time}}.",
"Integrations": "یکپارچه‌سازی‌ها",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "متصل به Readwise. آخرین همگام‌سازی {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "کتاب‌ها را از کاتالوگ‌های آنلاین مرور و دانلود کنید.",
"Connected as {{user}}": "متصل به عنوان {{user}}",
"Not connected": "متصل نیست",
"{{count}} catalog_one": "{{count}} کاتالوگ",
"{{count}} catalog_other": "{{count}} کاتالوگ",
"No catalogs": "بدون کاتالوگ",
"Connect Readest to external services for sync, highlights, and catalogs.": "Readest را برای همگام‌سازی، هایلایت‌ها و کاتالوگ‌ها به سرویس‌های خارجی متصل کنید.",
"Reading Sync": "همگام‌سازی مطالعه",
"Content Sources": "منابع محتوا",
"Scroll tabs": "پیمایش زبانه‌ها"
}
@@ -464,7 +464,6 @@
"Remove from Cloud Only": "Supprimer uniquement du Cloud",
"Remove from Device Only": "Supprimer uniquement de l'appareil",
"Disconnected": "Déconnecté",
"KOReader Sync Settings": "Paramètres de synchronisation KOReader",
"Sync Strategy": "Stratégie de synchronisation",
"Ask on conflict": "Demander en cas de conflit",
"Always use latest": "Toujours utiliser la dernière version",
@@ -482,10 +481,7 @@
"Connect": "Connecter",
"KOReader Sync": "Synchronisation KOReader",
"Sync Conflict": "Conflit de synchronisation",
"Sync reading progress from \"{{deviceName}}\"?": "Synchroniser la progression de lecture depuis \"{{deviceName}}\"?",
"another device": "un autre appareil",
"Local Progress": "Progression locale",
"Remote Progress": "Progression distante",
"Page {{page}} of {{total}} ({{percentage}}%)": "Page {{page}} sur {{total}} ({{percentage}}%)",
"Current position": "Position actuelle",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Environ page {{page}} sur {{total}} ({{percentage}}%)",
@@ -638,7 +634,6 @@
"Strikethrough": "Barré",
"Squiggly": "Ondulé",
"Outline": "Contour",
"Save Current Color": "Enregistrer la Couleur Actuelle",
"Quick Colors": "Couleurs Rapides",
"Highlighter": "Surligneur",
"Save Book Cover": "Enregistrer la Couverture du Livre",
@@ -1046,16 +1041,12 @@
"Invalid Readwise access token": "Jeton d'accès Readwise invalide",
"Disconnected from Readwise": "Déconnecté de Readwise",
"Never": "Jamais",
"Readwise Settings": "Paramètres Readwise",
"Connected to Readwise": "Connecté à Readwise",
"Last synced: {{time}}": "Dernière synchronisation : {{time}}",
"Sync Enabled": "Synchronisation activée",
"Disconnect": "Déconnecter",
"Connect your Readwise account to sync highlights.": "Connectez votre compte Readwise pour synchroniser les surlignages.",
"Get your access token at": "Obtenez votre jeton d'accès sur",
"Access Token": "Jeton d'accès",
"Paste your Readwise access token": "Collez votre jeton d'accès Readwise",
"Config": "Configuration",
"Readwise Sync": "Synchronisation Readwise",
"Push Highlights": "Pousser les surlignages",
"Highlights synced to Readwise": "Surlignages synchronisés avec Readwise",
@@ -1162,15 +1153,12 @@
"Drag to seek": "Glisser pour chercher",
"Punctuation Delay": "Délai de ponctuation",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Veuillez confirmer que cette connexion OPDS sera acheminée via les serveurs Readest sur l'application web avant de continuer.",
"Custom Headers": "En-têtes personnalisés",
"Custom Headers (optional)": "En-têtes personnalisés (facultatif)",
"Add one header per line using \"Header-Name: value\".": "Ajoutez un en-tête par ligne en utilisant \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Je comprends que cette connexion OPDS sera acheminée via les serveurs Readest sur l'application web. Si je ne fais pas confiance à Readest pour ces identifiants ou en-têtes, je devrais utiliser l'application native.",
"Unable to connect to Hardcover. Please check your network connection.": "Impossible de se connecter à Hardcover. Veuillez vérifier votre connexion réseau.",
"Invalid Hardcover API token": "Jeton API Hardcover invalide",
"Disconnected from Hardcover": "Déconnecté de Hardcover",
"Hardcover Settings": "Paramètres Hardcover",
"Connected to Hardcover": "Connecté à Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Connectez votre compte Hardcover pour synchroniser la progression de lecture et les notes.",
"Get your API token from hardcover.app → Settings → API.": "Obtenez votre jeton API depuis hardcover.app → Paramètres → API.",
"API Token": "Jeton API",
@@ -1422,5 +1410,35 @@
"Credentials": "Identifiants",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Jetons, noms d'utilisateur et mots de passe pour OPDS, KOReader, Hardcover et Readwise. Lorsqu'ils sont désactivés, les identifiants restent uniquement sur cet appareil et ne sont jamais téléversés.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Les champs sensibles synchronisés sont chiffrés avant l'envoi. Définissez une phrase de passe maintenant ou plus tard, lorsque le chiffrement sera nécessaire.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Enregistrée sur ce compte. Elle vous sera demandée lors du déchiffrement des identifiants."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Enregistrée sur ce compte. Elle vous sera demandée lors du déchiffrement des identifiants.",
"Reading progress on this device differs from \"{{deviceName}}\".": "La progression de lecture sur cet appareil diffère de « {{deviceName}} ».",
"This device": "Cet appareil",
"Set up KOReader Sync…": "Configurer KOReader Sync…",
"Set up Readwise…": "Configurer Readwise…",
"Set up Hardcover…": "Configurer Hardcover…",
"Red": "Rouge",
"Yellow": "Jaune",
"Green": "Vert",
"Blue": "Bleu",
"Violet": "Violet",
"Edit color": "Modifier la couleur",
"Add custom color": "Ajouter une couleur personnalisée",
"Add label": "Ajouter une étiquette",
"Choose color": "Choisir une couleur",
"Connected to Hardcover. Last synced {{time}}.": "Connecté à Hardcover. Dernière synchronisation {{time}}.",
"Integrations": "Intégrations",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Connecté à Readwise. Dernière synchronisation {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Parcourez et téléchargez des livres depuis des catalogues en ligne.",
"Connected as {{user}}": "Connecté en tant que {{user}}",
"Not connected": "Non connecté",
"{{count}} catalog_one": "{{count}} catalogue",
"{{count}} catalog_many": "{{count}} catalogues",
"{{count}} catalog_other": "{{count}} catalogues",
"No catalogs": "Aucun catalogue",
"Connect Readest to external services for sync, highlights, and catalogs.": "Connectez Readest à des services externes pour la synchronisation, les surlignages et les catalogues.",
"Reading Sync": "Synchronisation de lecture",
"Content Sources": "Sources de contenu",
"Scroll tabs": "Faire défiler les onglets"
}
@@ -414,13 +414,9 @@
"View Options": "אפשרויות תצוגה",
"Close Book": "סגור ספר",
"Sync Conflict": "קונפליקט סנכרון",
"Sync reading progress from \"{{deviceName}}\"?": "האם לסנכרן התקדמות קריאה מ-{{deviceName}}?",
"another device": "מכשיר אחר",
"Local Progress": "התקדמות מקומית",
"Remote Progress": "התקדמות מרחוק",
"Failed to connect": "החיבור נכשל",
"Disconnected": "מנותק",
"KOReader Sync Settings": "הגדרות סנכרון KOReader",
"Sync as {{userDisplayName}}": "סנכרן כ-{{userDisplayName}}",
"Sync Server Connected": "שרת הסנכרון מחובר",
"Sync Strategy": "אסטרטגיית סנכרון",
@@ -842,7 +838,6 @@
"Strikethrough": "קו חוצה",
"Squiggly": "קו גלי",
"Outline": "מתאר (Outline)",
"Save Current Color": "שמור צבע נוכחי",
"Quick Colors": "צבעים מהירים",
"Override Book Color": "דרוס צבע ספר",
"None": "ללא",
@@ -1046,16 +1041,12 @@
"Invalid Readwise access token": "אסימون גישה של Readwise לא חוקי",
"Disconnected from Readwise": "נותק מ-Readwise",
"Never": "לעולם לא",
"Readwise Settings": "הגדרות Readwise",
"Connected to Readwise": "מחובר ל-Readwise",
"Last synced: {{time}}": "סונכרן לאחרונה: {{time}}",
"Sync Enabled": "סנכרון מופעל",
"Disconnect": "נתק",
"Connect your Readwise account to sync highlights.": "חבר את חשבון Readwise שלך כדי לסנכרן את ההדגשות.",
"Get your access token at": "קבל את אסימון הגישה שלך ב-",
"Access Token": "אסימון גישה",
"Paste your Readwise access token": "הדבק את אסימון הגישה של Readwise שלך",
"Config": "תצורה",
"Readwise Sync": "סנכרון Readwise",
"Push Highlights": "שלח הדגשות",
"Highlights synced to Readwise": "ההדגשות סונכרנו ל-Readwise",
@@ -1162,15 +1153,12 @@
"Drag to seek": "גרור לחיפוש",
"Punctuation Delay": "השהיית פיסוק",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "אנא אשר שחיבור OPDS זה יועבר דרך שרתי Readest באפליקציית האינטרנט לפני שתמשיך.",
"Custom Headers": "כותרות מותאמות אישית",
"Custom Headers (optional)": "כותרות מותאמות אישית (אופציונלי)",
"Add one header per line using \"Header-Name: value\".": "הוסף כותרת אחת לכל שורה בפורמט \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "אני מבין שחיבור OPDS זה יועבר דרך שרתי Readest באפליקציית האינטרנט. אם אינני סומך על Readest עם פרטי ההתחברות או הכותרות האלה, עליי להשתמש באפליקציה המקורית.",
"Unable to connect to Hardcover. Please check your network connection.": "לא ניתן להתחבר ל-Hardcover. אנא בדוק את חיבור הרשת שלך.",
"Invalid Hardcover API token": "טוקן API של Hardcover לא חוקי",
"Disconnected from Hardcover": "מנותק מ-Hardcover",
"Hardcover Settings": "הגדרות Hardcover",
"Connected to Hardcover": "מחובר ל-Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "חבר את חשבון ה-Hardcover שלך לסנכרון התקדמות קריאה והערות.",
"Get your API token from hardcover.app → Settings → API.": "קבל את טוקן ה-API שלך מ-hardcover.app ← הגדרות ← API.",
"API Token": "טוקן API",
@@ -1422,5 +1410,35 @@
"Credentials": "אישורים",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "אסימונים, שמות משתמש וסיסמאות עבור OPDS, KOReader, Hardcover ו-Readwise. כאשר מבוטל, האישורים נשמרים רק במכשיר זה ולעולם אינם מועלים.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "שדות מסונכרנים רגישים מוצפנים לפני העלאה. הגדר ביטוי סיסמה עכשיו או מאוחר יותר, כאשר תידרש הצפנה.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "נשמר בחשבון זה. תתבקש להזינו בעת פענוח אישורים."
"Saved to this account. You will be prompted for it when decrypting credentials.": "נשמר בחשבון זה. תתבקש להזינו בעת פענוח אישורים.",
"Reading progress on this device differs from \"{{deviceName}}\".": "התקדמות הקריאה במכשיר זה שונה מ-\"{{deviceName}}\".",
"This device": "מכשיר זה",
"Set up KOReader Sync…": "הגדרת סנכרון KOReader…",
"Set up Readwise…": "הגדרת Readwise…",
"Set up Hardcover…": "הגדרת Hardcover…",
"Red": "אדום",
"Yellow": "צהוב",
"Green": "ירוק",
"Blue": "כחול",
"Violet": "סגול",
"Edit color": "עריכת צבע",
"Add custom color": "הוספת צבע מותאם אישית",
"Add label": "הוספת תווית",
"Choose color": "בחירת צבע",
"Connected to Hardcover. Last synced {{time}}.": "מחובר ל-Hardcover. סנכרון אחרון {{time}}.",
"Integrations": "שילובים",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "מחובר ל-Readwise. סנכרון אחרון {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "עיון והורדת ספרים מקטלוגים מקוונים.",
"Connected as {{user}}": "מחובר בתור {{user}}",
"Not connected": "לא מחובר",
"{{count}} catalog_one": "קטלוג אחד",
"{{count}} catalog_two": "{{count}} קטלוגים",
"{{count}} catalog_other": "{{count}} קטלוגים",
"No catalogs": "אין קטלוגים",
"Connect Readest to external services for sync, highlights, and catalogs.": "חבר את Readest לשירותים חיצוניים לסנכרון, הדגשות וקטלוגים.",
"Reading Sync": "סנכרון קריאה",
"Content Sources": "מקורות תוכן",
"Scroll tabs": "גלילת לשוניות"
}
@@ -460,7 +460,6 @@
"Remove from Cloud Only": "केवल क्लाउड से हटाएँ",
"Remove from Device Only": "केवल डिवाइस से हटाएँ",
"Disconnected": "अविचलित",
"KOReader Sync Settings": "KOReader सिंक सेटिंग्स",
"Sync Strategy": "सिंक रणनीति",
"Ask on conflict": "संघर्ष पर पूछें",
"Always use latest": "हमेशा नवीनतम का उपयोग करें",
@@ -478,10 +477,7 @@
"Connect": "कनेक्ट करें",
"KOReader Sync": "KOReader सिंक",
"Sync Conflict": "सिंक संघर्ष",
"Sync reading progress from \"{{deviceName}}\"?": "क्या आप \"{{deviceName}}\" से पढ़ने की प्रगति को सिंक करना चाहते हैं?",
"another device": "दूसरा डिवाइस",
"Local Progress": "स्थानीय प्रगति",
"Remote Progress": "दूरस्थ प्रगति",
"Page {{page}} of {{total}} ({{percentage}}%)": "पृष्ठ {{page}} कुल {{total}} ({{percentage}}%)",
"Current position": "वर्तमान स्थिति",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "लगभग पृष्ठ {{page}} कुल {{total}} ({{percentage}}%)",
@@ -634,7 +630,6 @@
"Strikethrough": "रेखांकित करें",
"Squiggly": "स्क्विगली",
"Outline": "आउटलाइन",
"Save Current Color": "वर्तमान रंग सहेजें",
"Quick Colors": "त्वरित रंग",
"Highlighter": "हाइलाइटर",
"Save Book Cover": "बुक कवर सहेजें",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "अमान्य Readwise एक्सेस टोकन",
"Disconnected from Readwise": "Readwise से डिस्कनेक्ट हो गया",
"Never": "कभी नहीं",
"Readwise Settings": "Readwise सेटिंग्स",
"Connected to Readwise": "Readwise से जुड़ा हुआ",
"Last synced: {{time}}": "पिछला सिंक: {{time}}",
"Sync Enabled": "सिंक सक्षम",
"Disconnect": "डिस्कनेक्ट करें",
"Connect your Readwise account to sync highlights.": "हाइलाइट्स सिंक करने के लिए अपना Readwise खाता कनेक्ट करें।",
"Get your access token at": "अपना एक्सेस टोकन यहाँ प्राप्त करें",
"Access Token": "एक्सेस टोकन",
"Paste your Readwise access token": "अपना Readwise एक्सेस टोकन पेस्ट करें",
"Config": "कॉन्फिग",
"Readwise Sync": "Readwise सिंक",
"Push Highlights": "हाइलाइट्स भेजें",
"Highlights synced to Readwise": "हाइलाइट्स Readwise में सिंक हो गए",
@@ -1149,15 +1140,12 @@
"Drag to seek": "खोजने के लिए खींचें",
"Punctuation Delay": "विराम चिह्न विलंब",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "कृपया जारी रखने से पहले पुष्टि करें कि यह OPDS कनेक्शन वेब ऐप पर Readest सर्वर के माध्यम से प्रॉक्सी किया जाएगा।",
"Custom Headers": "कस्टम हेडर",
"Custom Headers (optional)": "कस्टम हेडर (वैकल्पिक)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" का उपयोग करके प्रति पंक्ति एक हेडर जोड़ें।",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "मैं समझता/समझती हूँ कि यह OPDS कनेक्शन वेब ऐप पर Readest सर्वर के माध्यम से प्रॉक्सी किया जाएगा। यदि मुझे इन क्रेडेंशियल या हेडर के साथ Readest पर भरोसा नहीं है, तो मुझे नेटिव ऐप का उपयोग करना चाहिए।",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover से कनेक्ट करने में असमर्थ। कृपया अपना नेटवर्क कनेक्शन जाँचें।",
"Invalid Hardcover API token": "अमान्य Hardcover API टोकन",
"Disconnected from Hardcover": "Hardcover से डिस्कनेक्ट हो गया",
"Hardcover Settings": "Hardcover सेटिंग्स",
"Connected to Hardcover": "Hardcover से कनेक्टेड",
"Connect your Hardcover account to sync reading progress and notes.": "पढ़ने की प्रगति और नोट्स सिंक करने के लिए अपना Hardcover खाता कनेक्ट करें।",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → सेटिंग्स → API से अपना API टोकन प्राप्त करें।",
"API Token": "API टोकन",
@@ -1398,5 +1386,34 @@
"Credentials": "क्रेडेंशियल",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS, KOReader, Hardcover और Readwise के लिए टोकन, उपयोगकर्ता नाम और पासवर्ड। अक्षम होने पर, क्रेडेंशियल केवल इसी डिवाइस पर रहते हैं और कभी अपलोड नहीं किए जाते।",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "संवेदनशील सिंक किए गए फ़ील्ड अपलोड से पहले एन्क्रिप्ट किए जाते हैं। अभी पासफ़्रेज़ सेट करें या बाद में जब एन्क्रिप्शन की आवश्यकता हो।",
"Saved to this account. You will be prompted for it when decrypting credentials.": "इस खाते में सहेजा गया। क्रेडेंशियल डिक्रिप्ट करते समय आपसे पूछा जाएगा।"
"Saved to this account. You will be prompted for it when decrypting credentials.": "इस खाते में सहेजा गया। क्रेडेंशियल डिक्रिप्ट करते समय आपसे पूछा जाएगा।",
"Reading progress on this device differs from \"{{deviceName}}\".": "इस डिवाइस पर पढ़ने की प्रगति \"{{deviceName}}\" से भिन्न है।",
"This device": "यह डिवाइस",
"Set up KOReader Sync…": "KOReader सिंक सेट अप करें…",
"Set up Readwise…": "Readwise सेट अप करें…",
"Set up Hardcover…": "Hardcover सेट अप करें…",
"Red": "लाल",
"Yellow": "पीला",
"Green": "हरा",
"Blue": "नीला",
"Violet": "बैंगनी",
"Edit color": "रंग संपादित करें",
"Add custom color": "कस्टम रंग जोड़ें",
"Add label": "लेबल जोड़ें",
"Choose color": "रंग चुनें",
"Connected to Hardcover. Last synced {{time}}.": "Hardcover से कनेक्ट किया गया। अंतिम सिंक {{time}}।",
"Integrations": "एकीकरण",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwise से कनेक्ट किया गया। अंतिम सिंक {{time}}।",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "ऑनलाइन कैटलॉग से पुस्तकें ब्राउज़ करें और डाउनलोड करें।",
"Connected as {{user}}": "{{user}} के रूप में कनेक्ट किया गया",
"Not connected": "कनेक्ट नहीं है",
"{{count}} catalog_one": "{{count}} कैटलॉग",
"{{count}} catalog_other": "{{count}} कैटलॉग",
"No catalogs": "कोई कैटलॉग नहीं",
"Connect Readest to external services for sync, highlights, and catalogs.": "सिंक, हाइलाइट और कैटलॉग के लिए Readest को बाहरी सेवाओं से कनेक्ट करें।",
"Reading Sync": "रीडिंग सिंक",
"Content Sources": "सामग्री स्रोत",
"Scroll tabs": "टैब स्क्रॉल करें"
}
@@ -239,7 +239,6 @@
"Add your first OPDS catalog to start browsing books": "Adja hozzá első OPDS-katalógusát a böngészés megkezdéséhez",
"Add Your First Catalog": "Első katalógus hozzáadása",
"Username": "Felhasználónév",
"Custom Headers": "Egyéni fejlécek",
"Browse": "Böngészés",
"Popular Catalogs": "Népszerű katalógusok",
"Add": "Hozzáadás",
@@ -444,9 +443,6 @@
"Invalid Hardcover API token": "Érvénytelen Hardcover API token",
"Disconnected from Hardcover": "Leválasztva a Hardcover szolgáltatásról",
"Never": "Soha",
"Hardcover Settings": "Hardcover beállítások",
"Connected to Hardcover": "Csatlakozva a Hardcover szolgáltatáshoz",
"Last synced: {{time}}": "Utolsó szinkronizálás: {{time}}",
"Sync Enabled": "Szinkronizálás engedélyezve",
"Disconnect": "Leválasztás",
"Connect your Hardcover account to sync reading progress and notes.": "Csatlakoztassa Hardcover fiókját az olvasási haladás és jegyzetek szinkronizálásához.",
@@ -466,13 +462,9 @@
"Zoomed": "Nagyított",
"Zoom level": "Nagyítási szint",
"Sync Conflict": "Szinkronizálási ütközés",
"Sync reading progress from \"{{deviceName}}\"?": "Olvasási haladás szinkronizálása a(z) \"{{deviceName}}\" eszközről?",
"another device": "másik eszköz",
"Local Progress": "Helyi haladás",
"Remote Progress": "Távoli haladás",
"Failed to connect": "A csatlakozás nem sikerült",
"Disconnected": "Leválasztva",
"KOReader Sync Settings": "KOReader szinkronizálási beállítások",
"Sync as {{userDisplayName}}": "Szinkronizálás mint {{userDisplayName}}",
"Sync Server Connected": "Szinkronizálási szerver csatlakoztatva",
"Sync Strategy": "Szinkronizálási stratégia",
@@ -530,8 +522,6 @@
"Unable to connect to Readwise. Please check your network connection.": "Nem sikerült csatlakozni a Readwise szolgáltatáshoz. Kérjük, ellenőrizze a hálózati kapcsolatot.",
"Invalid Readwise access token": "Érvénytelen Readwise hozzáférési token",
"Disconnected from Readwise": "Leválasztva a Readwise szolgáltatásról",
"Readwise Settings": "Readwise beállítások",
"Connected to Readwise": "Csatlakozva a Readwise szolgáltatáshoz",
"Connect your Readwise account to sync highlights.": "Csatlakoztassa Readwise fiókját a kiemelések szinkronizálásához.",
"Get your access token at": "Szerezze be hozzáférési tokenjét itt:",
"Access Token": "Hozzáférési token",
@@ -588,7 +578,6 @@
"Exit Parallel Read": "Kilépés a párhuzamos olvasásból",
"Enter Parallel Read": "Párhuzamos olvasás indítása",
"KOReader Sync": "KOReader szinkronizálás",
"Config": "Beállítás",
"Push Progress": "Haladás küldése",
"Pull Progress": "Haladás letöltése",
"Readwise Sync": "Readwise szinkronizálás",
@@ -931,7 +920,6 @@
"Strikethrough": "Áthúzás",
"Squiggly": "Hullámos",
"Outline": "Körvonal",
"Save Current Color": "Jelenlegi szín mentése",
"Quick Colors": "Gyors színek",
"Override Book Color": "Könyv színeinek felülírása",
"None": "Nincs",
@@ -1398,5 +1386,34 @@
"Credentials": "Hitelesítő adatok",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Tokenek, felhasználónevek és jelszavak az OPDS, KOReader, Hardcover és Readwise szolgáltatásokhoz. Ha le van tiltva, a hitelesítő adatok csak ezen az eszközön maradnak, és soha nem töltődnek fel.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Az érzékeny szinkronizált mezők feltöltés előtt titkosítva vannak. Állíts be egy jelmondatot most, vagy később, amikor titkosításra lesz szükség.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Mentve ehhez a fiókhoz. Akkor kérjük majd, amikor hitelesítő adatokat kell visszafejteni."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Mentve ehhez a fiókhoz. Akkor kérjük majd, amikor hitelesítő adatokat kell visszafejteni.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Az olvasási előrehaladás ezen az eszközön eltér a következőtől: „{{deviceName}}”.",
"This device": "Ez az eszköz",
"Set up KOReader Sync…": "KOReader-szinkronizálás beállítása…",
"Set up Readwise…": "Readwise beállítása…",
"Set up Hardcover…": "Hardcover beállítása…",
"Red": "Piros",
"Yellow": "Sárga",
"Green": "Zöld",
"Blue": "Kék",
"Violet": "Lila",
"Edit color": "Szín szerkesztése",
"Add custom color": "Egyéni szín hozzáadása",
"Add label": "Címke hozzáadása",
"Choose color": "Válasszon színt",
"Connected to Hardcover. Last synced {{time}}.": "Csatlakozva a Hardcover-hez. Utoljára szinkronizálva: {{time}}.",
"Integrations": "Integrációk",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Csatlakozva a Readwise-hoz. Utoljára szinkronizálva: {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Tallózzon és töltsön le könyveket online katalógusokból.",
"Connected as {{user}}": "Csatlakozva mint {{user}}",
"Not connected": "Nincs csatlakoztatva",
"{{count}} catalog_one": "{{count}} katalógus",
"{{count}} catalog_other": "{{count}} katalógus",
"No catalogs": "Nincs katalógus",
"Connect Readest to external services for sync, highlights, and catalogs.": "Csatlakoztassa a Readest-et külső szolgáltatásokhoz szinkronizálás, kiemelések és katalógusok használatához.",
"Reading Sync": "Olvasási szinkronizálás",
"Content Sources": "Tartalomforrások",
"Scroll tabs": "Lapok görgetése"
}
@@ -456,7 +456,6 @@
"Remove from Cloud Only": "Hapus dari Cloud Saja",
"Remove from Device Only": "Hapus dari Perangkat Saja",
"Disconnected": "Terputus",
"KOReader Sync Settings": "Pengaturan Sinkronisasi KOReader",
"Sync Strategy": "Strategi Sinkronisasi",
"Ask on conflict": "Tanya saat terjadi konflik",
"Always use latest": "Selalu gunakan yang terbaru",
@@ -474,10 +473,7 @@
"Connect": "Hubungkan",
"KOReader Sync": "Sinkronisasi KOReader",
"Sync Conflict": "Konflik Sinkronisasi",
"Sync reading progress from \"{{deviceName}}\"?": "Sinkronkan progres membaca dari \"{{deviceName}}\"?",
"another device": "perangkat lain",
"Local Progress": "Progres Lokal",
"Remote Progress": "Progres Jarak Jauh",
"Page {{page}} of {{total}} ({{percentage}}%)": "Halaman {{page}} dari {{total}} ({{percentage}}%)",
"Current position": "Posisi Saat Ini",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Sekitar halaman {{page}} dari {{total}} ({{percentage}}%)",
@@ -630,7 +626,6 @@
"Strikethrough": "Garis Tengah",
"Squiggly": "Bergelombang",
"Outline": "Garis Besar",
"Save Current Color": "Simpan Warna Saat Ini",
"Quick Colors": "Warna Cepat",
"Highlighter": "Penyorot",
"Save Book Cover": "Simpan Sampul Buku",
@@ -1022,16 +1017,12 @@
"Invalid Readwise access token": "Token akses Readwise tidak valid",
"Disconnected from Readwise": "Terputus dari Readwise",
"Never": "Tidak pernah",
"Readwise Settings": "Pengaturan Readwise",
"Connected to Readwise": "Terhubung ke Readwise",
"Last synced: {{time}}": "Terakhir disinkronkan: {{time}}",
"Sync Enabled": "Sinkronisasi Diaktifkan",
"Disconnect": "Putuskan sambungan",
"Connect your Readwise account to sync highlights.": "Hubungkan akun Readwise Anda untuk menyinkronkan sorotan.",
"Get your access token at": "Dapatkan token akses Anda di",
"Access Token": "Token Akses",
"Paste your Readwise access token": "Tempelkan token akses Readwise Anda",
"Config": "Konfigurasi",
"Readwise Sync": "Sinkronisasi Readwise",
"Push Highlights": "Kirim Sorotan",
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
@@ -1136,15 +1127,12 @@
"Drag to seek": "Seret untuk mencari",
"Punctuation Delay": "Jeda tanda baca",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Harap konfirmasi bahwa koneksi OPDS ini akan diteruskan melalui server Readest di aplikasi web sebelum melanjutkan.",
"Custom Headers": "Header Kustom",
"Custom Headers (optional)": "Header Kustom (opsional)",
"Add one header per line using \"Header-Name: value\".": "Tambahkan satu header per baris menggunakan \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Saya memahami bahwa koneksi OPDS ini akan diteruskan melalui server Readest di aplikasi web. Jika saya tidak mempercayai Readest dengan kredensial atau header ini, saya harus menggunakan aplikasi native.",
"Unable to connect to Hardcover. Please check your network connection.": "Tidak dapat terhubung ke Hardcover. Periksa koneksi jaringan Anda.",
"Invalid Hardcover API token": "Token API Hardcover tidak valid",
"Disconnected from Hardcover": "Terputus dari Hardcover",
"Hardcover Settings": "Pengaturan Hardcover",
"Connected to Hardcover": "Terhubung ke Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Hubungkan akun Hardcover Anda untuk menyinkronkan progres membaca dan catatan.",
"Get your API token from hardcover.app → Settings → API.": "Dapatkan token API Anda dari hardcover.app → Pengaturan → API.",
"API Token": "Token API",
@@ -1374,5 +1362,33 @@
"Credentials": "Kredensial",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Token, nama pengguna, dan kata sandi untuk OPDS, KOReader, Hardcover, dan Readwise. Saat dinonaktifkan, kredensial hanya tetap di perangkat ini dan tidak pernah diunggah.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Bidang sinkron yang sensitif dienkripsi sebelum diunggah. Atur frasa sandi sekarang atau nanti saat enkripsi diperlukan.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Disimpan di akun ini. Anda akan diminta memasukkannya saat mendekripsi kredensial."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Disimpan di akun ini. Anda akan diminta memasukkannya saat mendekripsi kredensial.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Progres membaca di perangkat ini berbeda dari \"{{deviceName}}\".",
"This device": "Perangkat ini",
"Set up KOReader Sync…": "Siapkan Sinkronisasi KOReader…",
"Set up Readwise…": "Siapkan Readwise…",
"Set up Hardcover…": "Siapkan Hardcover…",
"Red": "Merah",
"Yellow": "Kuning",
"Green": "Hijau",
"Blue": "Biru",
"Violet": "Ungu",
"Edit color": "Edit warna",
"Add custom color": "Tambah warna kustom",
"Add label": "Tambah label",
"Choose color": "Pilih warna",
"Connected to Hardcover. Last synced {{time}}.": "Terhubung ke Hardcover. Sinkronisasi terakhir {{time}}.",
"Integrations": "Integrasi",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Terhubung ke Readwise. Sinkronisasi terakhir {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Telusuri dan unduh buku dari katalog online.",
"Connected as {{user}}": "Terhubung sebagai {{user}}",
"Not connected": "Tidak terhubung",
"{{count}} catalog_other": "{{count}} katalog",
"No catalogs": "Tidak ada katalog",
"Connect Readest to external services for sync, highlights, and catalogs.": "Hubungkan Readest ke layanan eksternal untuk sinkronisasi, sorotan, dan katalog.",
"Reading Sync": "Sinkronisasi Membaca",
"Content Sources": "Sumber Konten",
"Scroll tabs": "Gulir tab"
}
@@ -464,7 +464,6 @@
"Remove from Cloud Only": "Rimuovi solo da Cloud",
"Remove from Device Only": "Rimuovi solo da Dispositivo",
"Disconnected": "Disconnesso",
"KOReader Sync Settings": "Impostazioni di Sincronizzazione KOReader",
"Sync Strategy": "Strategia di Sincronizzazione",
"Ask on conflict": "Chiedi in caso di conflitto",
"Always use latest": "Usa sempre l'ultima versione",
@@ -482,10 +481,7 @@
"Connect": "Connetti",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Conflitto di Sincronizzazione",
"Sync reading progress from \"{{deviceName}}\"?": "Sincronizzare il progresso di lettura da \"{{deviceName}}\"?",
"another device": "un altro dispositivo",
"Local Progress": "Progresso Locale",
"Remote Progress": "Progresso Remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Pagina {{page}} di {{total}} ({{percentage}}%)",
"Current position": "Posizione Corrente",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Circa pagina {{page}} di {{total}} ({{percentage}}%)",
@@ -638,7 +634,6 @@
"Strikethrough": "Barrato",
"Squiggly": "Ondulato",
"Outline": "Contorno",
"Save Current Color": "Salva Colore Corrente",
"Quick Colors": "Colori Veloci",
"Highlighter": "Evidenziatore",
"Save Book Cover": "Salva Copertina del Libro",
@@ -1046,16 +1041,12 @@
"Invalid Readwise access token": "Token di accesso Readwise non valido",
"Disconnected from Readwise": "Disconnesso da Readwise",
"Never": "Mai",
"Readwise Settings": "Impostazioni Readwise",
"Connected to Readwise": "Connesso a Readwise",
"Last synced: {{time}}": "Ultima sincronizzazione: {{time}}",
"Sync Enabled": "Sincronizzazione abilitata",
"Disconnect": "Disconnetti",
"Connect your Readwise account to sync highlights.": "Connetti il tuo account Readwise per sincronizzare le evidenziazioni.",
"Get your access token at": "Ottieni il tuo token di accesso su",
"Access Token": "Token di accesso",
"Paste your Readwise access token": "Incolla il tuo token di accesso Readwise",
"Config": "Configurazione",
"Readwise Sync": "Sincronizzazione Readwise",
"Push Highlights": "Invia evidenziazioni",
"Highlights synced to Readwise": "Evidenziazioni sincronizzate su Readwise",
@@ -1162,15 +1153,12 @@
"Drag to seek": "Trascina per cercare",
"Punctuation Delay": "Ritardo punteggiatura",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Conferma che questa connessione OPDS verrà instradata attraverso i server Readest nell'app web prima di continuare.",
"Custom Headers": "Intestazioni personalizzate",
"Custom Headers (optional)": "Intestazioni personalizzate (facoltativo)",
"Add one header per line using \"Header-Name: value\".": "Aggiungi un'intestazione per riga usando \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Capisco che questa connessione OPDS verrà instradata attraverso i server Readest nell'app web. Se non mi fido di Readest con queste credenziali o intestazioni, dovrei usare l'app nativa.",
"Unable to connect to Hardcover. Please check your network connection.": "Impossibile connettersi a Hardcover. Controlla la connessione di rete.",
"Invalid Hardcover API token": "Token API Hardcover non valido",
"Disconnected from Hardcover": "Disconnesso da Hardcover",
"Hardcover Settings": "Impostazioni Hardcover",
"Connected to Hardcover": "Connesso a Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Collega il tuo account Hardcover per sincronizzare i progressi di lettura e le note.",
"Get your API token from hardcover.app → Settings → API.": "Ottieni il tuo token API da hardcover.app → Impostazioni → API.",
"API Token": "Token API",
@@ -1422,5 +1410,35 @@
"Credentials": "Credenziali",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Token, nomi utente e password per OPDS, KOReader, Hardcover e Readwise. Quando disattivato, le credenziali restano solo su questo dispositivo e non vengono mai caricate.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "I campi sincronizzati sensibili vengono cifrati prima del caricamento. Imposta una passphrase ora o più tardi, quando servirà la cifratura.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Salvata in questo account. Ti verrà richiesta quando occorrerà decifrare le credenziali."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Salvata in questo account. Ti verrà richiesta quando occorrerà decifrare le credenziali.",
"Reading progress on this device differs from \"{{deviceName}}\".": "L'avanzamento di lettura su questo dispositivo è diverso da «{{deviceName}}».",
"This device": "Questo dispositivo",
"Set up KOReader Sync…": "Configura KOReader Sync…",
"Set up Readwise…": "Configura Readwise…",
"Set up Hardcover…": "Configura Hardcover…",
"Red": "Rosso",
"Yellow": "Giallo",
"Green": "Verde",
"Blue": "Blu",
"Violet": "Viola",
"Edit color": "Modifica colore",
"Add custom color": "Aggiungi colore personalizzato",
"Add label": "Aggiungi etichetta",
"Choose color": "Scegli colore",
"Connected to Hardcover. Last synced {{time}}.": "Collegato a Hardcover. Ultima sincronizzazione {{time}}.",
"Integrations": "Integrazioni",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Collegato a Readwise. Ultima sincronizzazione {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Sfoglia e scarica libri dai cataloghi online.",
"Connected as {{user}}": "Collegato come {{user}}",
"Not connected": "Non collegato",
"{{count}} catalog_one": "{{count}} catalogo",
"{{count}} catalog_many": "{{count}} cataloghi",
"{{count}} catalog_other": "{{count}} cataloghi",
"No catalogs": "Nessun catalogo",
"Connect Readest to external services for sync, highlights, and catalogs.": "Collega Readest a servizi esterni per sincronizzazione, evidenziazioni e cataloghi.",
"Reading Sync": "Sincronizzazione lettura",
"Content Sources": "Fonti di contenuti",
"Scroll tabs": "Scorri schede"
}
@@ -456,7 +456,6 @@
"Remove from Cloud Only": "クラウドからのみ削除",
"Remove from Device Only": "デバイスからのみ削除",
"Disconnected": "切断されました",
"KOReader Sync Settings": "KOReader Sync設定",
"Sync Strategy": "同期戦略",
"Ask on conflict": "競合時に確認",
"Always use latest": "最新を常に使用",
@@ -474,10 +473,7 @@
"Connect": "接続",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "同期競合",
"Sync reading progress from \"{{deviceName}}\"?": "{{deviceName}}から読書進捗を同期しますか?",
"another device": "別のデバイス",
"Local Progress": "ローカル進捗",
"Remote Progress": "リモート進捗",
"Page {{page}} of {{total}} ({{percentage}}%)": "ページ {{page}} / {{total}} ({{percentage}}%)",
"Current position": "現在の位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "おおよそページ {{page}} / {{total}} ({{percentage}}%)",
@@ -630,7 +626,6 @@
"Strikethrough": "取り消し線",
"Squiggly": "波線",
"Outline": "アウトライン",
"Save Current Color": "現在の色を保存",
"Quick Colors": "クイックカラー",
"Highlighter": "蛍光ペン",
"Save Book Cover": "本のカバーを保存",
@@ -1022,16 +1017,12 @@
"Invalid Readwise access token": "無効な Readwise アクセストークン",
"Disconnected from Readwise": "Readwise から切断されました",
"Never": "一度もなし",
"Readwise Settings": "Readwise 設定",
"Connected to Readwise": "Readwise に接続済み",
"Last synced: {{time}}": "最終同期:{{time}}",
"Sync Enabled": "同期有効",
"Disconnect": "切断",
"Connect your Readwise account to sync highlights.": "ハイライトを同期するには Readwise アカウントを接続してください。",
"Get your access token at": "アクセストークンの取得先:",
"Access Token": "アクセストークン",
"Paste your Readwise access token": "Readwise アクセストークンを貼り付けてください",
"Config": "設定",
"Readwise Sync": "Readwise 同期",
"Push Highlights": "ハイライトをプッシュ",
"Highlights synced to Readwise": "ハイライトが Readwise に同期されました",
@@ -1136,15 +1127,12 @@
"Drag to seek": "ドラッグしてシーク",
"Punctuation Delay": "句読点の遅延",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "続行する前に、このOPDS接続がWebアプリのReadestサーバーを経由してプロキシされることを確認してください。",
"Custom Headers": "カスタムヘッダー",
"Custom Headers (optional)": "カスタムヘッダー(任意)",
"Add one header per line using \"Header-Name: value\".": "「Header-Name: value」の形式で1行に1つのヘッダーを追加してください。",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "このOPDS接続がWebアプリのReadestサーバーを経由してプロキシされることを理解しています。これらの認証情報やヘッダーについてReadestを信頼しない場合は、ネイティブアプリを使用してください。",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcoverに接続できません。ネットワーク接続を確認してください。",
"Invalid Hardcover API token": "Hardcover APIトークンが無効です",
"Disconnected from Hardcover": "Hardcoverから切断されました",
"Hardcover Settings": "Hardcover設定",
"Connected to Hardcover": "Hardcoverに接続済み",
"Connect your Hardcover account to sync reading progress and notes.": "Hardcoverアカウントを接続して、読書の進捗やメモを同期しましょう。",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → 設定 → APIからAPIトークンを取得してください。",
"API Token": "APIトークン",
@@ -1374,5 +1362,33 @@
"Credentials": "認証情報",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS、KOReader、Hardcover、Readwise のトークン、ユーザー名、パスワード。無効にすると、認証情報はこのデバイスにのみ残り、アップロードされません。",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "機密性の高い同期フィールドはアップロード前に暗号化されます。パスフレーズは今すぐ設定するか、暗号化が必要なときに後で設定できます。",
"Saved to this account. You will be prompted for it when decrypting credentials.": "このアカウントに保存されました。認証情報を復号する際に入力を求められます。"
"Saved to this account. You will be prompted for it when decrypting credentials.": "このアカウントに保存されました。認証情報を復号する際に入力を求められます。",
"Reading progress on this device differs from \"{{deviceName}}\".": "このデバイスの読書進捗は「{{deviceName}}」と異なります。",
"This device": "このデバイス",
"Set up KOReader Sync…": "KOReader同期を設定…",
"Set up Readwise…": "Readwiseを設定…",
"Set up Hardcover…": "Hardcoverを設定…",
"Red": "赤",
"Yellow": "黄",
"Green": "緑",
"Blue": "青",
"Violet": "紫",
"Edit color": "色を編集",
"Add custom color": "カスタムカラーを追加",
"Add label": "ラベルを追加",
"Choose color": "色を選択",
"Connected to Hardcover. Last synced {{time}}.": "Hardcoverに接続済み。最終同期 {{time}}。",
"Integrations": "連携",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwiseに接続済み。最終同期 {{time}}。",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "オンラインカタログから書籍を閲覧・ダウンロードします。",
"Connected as {{user}}": "{{user}} として接続済み",
"Not connected": "未接続",
"{{count}} catalog_other": "{{count}} カタログ",
"No catalogs": "カタログなし",
"Connect Readest to external services for sync, highlights, and catalogs.": "同期、ハイライト、カタログのために Readest を外部サービスに接続します。",
"Reading Sync": "読書同期",
"Content Sources": "コンテンツソース",
"Scroll tabs": "タブをスクロール"
}
@@ -456,7 +456,6 @@
"Remove from Cloud Only": "클라우드에서만 제거",
"Remove from Device Only": "장치에서만 제거",
"Disconnected": "연결 끊김",
"KOReader Sync Settings": "KOReader Sync 설정",
"Sync Strategy": "동기화 전략",
"Ask on conflict": "충돌 시 묻기",
"Always use latest": "항상 최신 버전 사용",
@@ -474,10 +473,7 @@
"Connect": "연결",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "동기화 충돌",
"Sync reading progress from \"{{deviceName}}\"?": " \"{{deviceName}}\"에서 읽기 진행 상황을 동기화하시겠습니까?",
"another device": "다른 장치",
"Local Progress": "로컬 진행 상황",
"Remote Progress": "원격 진행 상황",
"Page {{page}} of {{total}} ({{percentage}}%)": "페이지 {{page}} / {{total}} ({{percentage}}%)",
"Current position": "현재 위치",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "대략 페이지 {{page}} / {{total}} ({{percentage}}%)",
@@ -630,7 +626,6 @@
"Strikethrough": "취소선",
"Squiggly": "물결선",
"Outline": "윤곽선",
"Save Current Color": "현재 색상 저장",
"Quick Colors": "빠른 색상",
"Highlighter": "형광펜",
"Save Book Cover": "책 표지 저장",
@@ -1022,16 +1017,12 @@
"Invalid Readwise access token": "유효하지 않은 Readwise 액세스 토큰입니다",
"Disconnected from Readwise": "Readwise와 연결이 끊어졌습니다",
"Never": "안 함",
"Readwise Settings": "Readwise 설정",
"Connected to Readwise": "Readwise에 연결됨",
"Last synced: {{time}}": "마지막 동기화: {{time}}",
"Sync Enabled": "동기화 활성화됨",
"Disconnect": "연결 해제",
"Connect your Readwise account to sync highlights.": "하이라이트를 동기화하려면 Readwise 계정렬 연결하세요.",
"Get your access token at": "액세스 토큰 받기:",
"Access Token": "액세스 토큰",
"Paste your Readwise access token": "Readwise 액세스 토큰을 붙여넣으세요",
"Config": "구성",
"Readwise Sync": "Readwise 동기화",
"Push Highlights": "하이라이트 푸시",
"Highlights synced to Readwise": "하이라이트가 Readwise에 동기화되었습니다",
@@ -1136,15 +1127,12 @@
"Drag to seek": "드래그하여 탐색",
"Punctuation Delay": "구두점 지연",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "계속하기 전에 이 OPDS 연결이 웹 앱의 Readest 서버를 통해 프록시될 것임을 확인해 주세요.",
"Custom Headers": "사용자 정의 헤더",
"Custom Headers (optional)": "사용자 정의 헤더 (선택 사항)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" 형식으로 한 줄에 하나의 헤더를 추가하세요.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "이 OPDS 연결이 웹 앱의 Readest 서버를 통해 프록시될 것임을 이해합니다. 이 자격 증명이나 헤더에 대해 Readest를 신뢰하지 않는다면 네이티브 앱을 사용해야 합니다.",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover에 연결할 수 없습니다. 네트워크 연결을 확인해 주세요.",
"Invalid Hardcover API token": "유효하지 않은 Hardcover API 토큰",
"Disconnected from Hardcover": "Hardcover에서 연결 해제됨",
"Hardcover Settings": "Hardcover 설정",
"Connected to Hardcover": "Hardcover에 연결됨",
"Connect your Hardcover account to sync reading progress and notes.": "독서 진행 상황과 메모를 동기화하려면 Hardcover 계정을 연결하세요.",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → 설정 → API에서 API 토큰을 받으세요.",
"API Token": "API 토큰",
@@ -1374,5 +1362,33 @@
"Credentials": "자격 증명",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS, KOReader, Hardcover, Readwise의 토큰, 사용자 이름, 비밀번호입니다. 비활성화되면 자격 증명이 이 기기에만 남아 있고 절대 업로드되지 않습니다.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "민감한 동기화 필드는 업로드 전에 암호화됩니다. 지금 또는 암호화가 필요할 때 나중에 암호 문구를 설정하세요.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "이 계정에 저장되었습니다. 자격 증명을 복호화할 때 입력하라는 메시지가 표시됩니다."
"Saved to this account. You will be prompted for it when decrypting credentials.": "이 계정에 저장되었습니다. 자격 증명을 복호화할 때 입력하라는 메시지가 표시됩니다.",
"Reading progress on this device differs from \"{{deviceName}}\".": "이 기기의 읽기 진행 상황이 \"{{deviceName}}\"와(과) 다릅니다.",
"This device": "이 기기",
"Set up KOReader Sync…": "KOReader 동기화 설정…",
"Set up Readwise…": "Readwise 설정…",
"Set up Hardcover…": "Hardcover 설정…",
"Red": "빨강",
"Yellow": "노랑",
"Green": "초록",
"Blue": "파랑",
"Violet": "보라",
"Edit color": "색상 편집",
"Add custom color": "사용자 지정 색상 추가",
"Add label": "라벨 추가",
"Choose color": "색상 선택",
"Connected to Hardcover. Last synced {{time}}.": "Hardcover에 연결됨. 마지막 동기화 {{time}}.",
"Integrations": "통합",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwise에 연결됨. 마지막 동기화 {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "온라인 카탈로그에서 책을 둘러보고 다운로드하세요.",
"Connected as {{user}}": "{{user}}(으)로 연결됨",
"Not connected": "연결되지 않음",
"{{count}} catalog_other": "카탈로그 {{count}}개",
"No catalogs": "카탈로그 없음",
"Connect Readest to external services for sync, highlights, and catalogs.": "동기화, 하이라이트, 카탈로그를 위해 Readest를 외부 서비스에 연결하세요.",
"Reading Sync": "읽기 동기화",
"Content Sources": "콘텐츠 소스",
"Scroll tabs": "탭 스크롤"
}
@@ -212,13 +212,9 @@
"Header Bar": "Bar Pengepala",
"View Options": "Pilihan Paparan",
"Sync Conflict": "Konflik Segerak",
"Sync reading progress from \"{{deviceName}}\"?": "Segerakkan kemajuan membaca dari \"{{deviceName}}\"?",
"another device": "peranti lain",
"Local Progress": "Kemajuan Tempatan",
"Remote Progress": "Kemajuan Jauh",
"Failed to connect": "Gagal menyambung",
"Disconnected": "Terputus",
"KOReader Sync Settings": "Tetapan Segerak KOReader",
"Sync as {{userDisplayName}}": "Segerak sebagai {{userDisplayName}}",
"Sync Server Connected": "Pelayan Segerak Disambung",
"Sync Strategy": "Strategi Segerak",
@@ -495,7 +491,6 @@
"Strikethrough": "Garis Lorek",
"Squiggly": "Berkelok-kelok",
"Outline": "Garis Besar",
"Save Current Color": "Simpan Warna Semasa",
"Quick Colors": "Warna Pantas",
"Override Book Color": "Ganti Warna Buku",
"Scroll": "Tatal",
@@ -1022,16 +1017,12 @@
"Invalid Readwise access token": "Token akses Readwise tidak sah",
"Disconnected from Readwise": "Terputus sambungan daripada Readwise",
"Never": "Tidak pernah",
"Readwise Settings": "Tetapan Readwise",
"Connected to Readwise": "Disambungkan ke Readwise",
"Last synced: {{time}}": "Kali terakhir disinkronkan: {{time}}",
"Sync Enabled": "Penyinkronan Didayakan",
"Disconnect": "Putuskan sambungan",
"Connect your Readwise account to sync highlights.": "Sambungkan akaun Readwise anda untuk menyinkronkan sorotan.",
"Get your access token at": "Dapatkan token akses anda di",
"Access Token": "Token Akses",
"Paste your Readwise access token": "Tampal token akses Readwise anda",
"Config": "Konfigurasi",
"Readwise Sync": "Penyinkronan Readwise",
"Push Highlights": "Hantar Sorotan",
"Highlights synced to Readwise": "Sorotan disinkronkan ke Readwise",
@@ -1136,15 +1127,12 @@
"Drag to seek": "Seret untuk mencari",
"Punctuation Delay": "Kelewatan tanda baca",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Sila sahkan bahawa sambungan OPDS ini akan diproksi melalui pelayan Readest pada aplikasi web sebelum meneruskan.",
"Custom Headers": "Pengepala Tersuai",
"Custom Headers (optional)": "Pengepala Tersuai (pilihan)",
"Add one header per line using \"Header-Name: value\".": "Tambah satu pengepala setiap baris menggunakan \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Saya faham bahawa sambungan OPDS ini akan diproksi melalui pelayan Readest pada aplikasi web. Jika saya tidak mempercayai Readest dengan kelayakan atau pengepala ini, saya perlu menggunakan aplikasi natif.",
"Unable to connect to Hardcover. Please check your network connection.": "Tidak dapat menyambung ke Hardcover. Sila semak sambungan rangkaian anda.",
"Invalid Hardcover API token": "Token API Hardcover tidak sah",
"Disconnected from Hardcover": "Diputuskan dari Hardcover",
"Hardcover Settings": "Tetapan Hardcover",
"Connected to Hardcover": "Disambungkan ke Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Sambungkan akaun Hardcover anda untuk menyegerakkan kemajuan bacaan dan nota.",
"Get your API token from hardcover.app → Settings → API.": "Dapatkan token API anda dari hardcover.app → Tetapan → API.",
"API Token": "Token API",
@@ -1374,5 +1362,33 @@
"Credentials": "Kelayakan",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Token, nama pengguna dan kata laluan untuk OPDS, KOReader, Hardcover dan Readwise. Apabila dilumpuhkan, kelayakan hanya kekal pada peranti ini dan tidak pernah dimuat naik.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Medan disegerakkan yang sensitif disulitkan sebelum dimuat naik. Tetapkan frasa laluan sekarang atau kemudian apabila penyulitan diperlukan.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Disimpan dalam akaun ini. Anda akan diminta memasukkannya apabila kelayakan perlu dinyahsulit."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Disimpan dalam akaun ini. Anda akan diminta memasukkannya apabila kelayakan perlu dinyahsulit.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Kemajuan membaca pada peranti ini berbeza daripada \"{{deviceName}}\".",
"This device": "Peranti ini",
"Set up KOReader Sync…": "Sediakan Penyegerakan KOReader…",
"Set up Readwise…": "Sediakan Readwise…",
"Set up Hardcover…": "Sediakan Hardcover…",
"Red": "Merah",
"Yellow": "Kuning",
"Green": "Hijau",
"Blue": "Biru",
"Violet": "Ungu",
"Edit color": "Edit warna",
"Add custom color": "Tambah warna tersuai",
"Add label": "Tambah label",
"Choose color": "Pilih warna",
"Connected to Hardcover. Last synced {{time}}.": "Disambungkan ke Hardcover. Penyegerakan terakhir {{time}}.",
"Integrations": "Integrasi",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Disambungkan ke Readwise. Penyegerakan terakhir {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Lihat dan muat turun buku dari katalog dalam talian.",
"Connected as {{user}}": "Disambungkan sebagai {{user}}",
"Not connected": "Tidak disambungkan",
"{{count}} catalog_other": "{{count}} katalog",
"No catalogs": "Tiada katalog",
"Connect Readest to external services for sync, highlights, and catalogs.": "Sambungkan Readest ke perkhidmatan luar untuk penyegerakan, sorotan dan katalog.",
"Reading Sync": "Penyegerakan Bacaan",
"Content Sources": "Sumber Kandungan",
"Scroll tabs": "Skrol tab"
}
@@ -460,7 +460,6 @@
"Remove from Cloud Only": "Verwijder alleen uit Cloud",
"Remove from Device Only": "Verwijder alleen uit Apparaat",
"Disconnected": "Verbroken",
"KOReader Sync Settings": "KOReader Sync-instellingen",
"Sync Strategy": "Synchronisatiestrategie",
"Ask on conflict": "Vraag bij conflict",
"Always use latest": "Altijd de nieuwste gebruiken",
@@ -478,10 +477,7 @@
"Connect": "Verbinden",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Synchronisatieconflict",
"Sync reading progress from \"{{deviceName}}\"?": "Leesvoortgang synchroniseren van \"{{deviceName}}\"?",
"another device": "een ander apparaat",
"Local Progress": "Lokale voortgang",
"Remote Progress": "Externe voortgang",
"Page {{page}} of {{total}} ({{percentage}}%)": "Pagina {{page}} van {{total}} ({{percentage}}%)",
"Current position": "Huidige positie",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Ongeveer pagina {{page}} van {{total}} ({{percentage}}%)",
@@ -634,7 +630,6 @@
"Strikethrough": "Doorhalen",
"Squiggly": "Kronkelig",
"Outline": "Omtrek",
"Save Current Color": "Huidige kleur opslaan",
"Quick Colors": "Snelle kleuren",
"Highlighter": "Marker",
"Save Book Cover": "Opslaan boekomslag",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "Ongeldige Readwise-toegangstoken",
"Disconnected from Readwise": "Verbinding met Readwise verbroken",
"Never": "Nooit",
"Readwise Settings": "Readwise-instellingen",
"Connected to Readwise": "Verbonden met Readwise",
"Last synced: {{time}}": "Laatst gesynchroniseerd: {{time}}",
"Sync Enabled": "Synchronisatie ingeschakeld",
"Disconnect": "Verbinding verbreken",
"Connect your Readwise account to sync highlights.": "Verbind uw Readwise-account om markeringen te synchroniseren.",
"Get your access token at": "Verkrijg uw toegangstoken op",
"Access Token": "Toegangstoken",
"Paste your Readwise access token": "Plak uw Readwise-toegangstoken",
"Config": "Configuratie",
"Readwise Sync": "Readwise-synchronisatie",
"Push Highlights": "Markeringen verzenden",
"Highlights synced to Readwise": "Markeringen gesynchroniseerd met Readwise",
@@ -1149,15 +1140,12 @@
"Drag to seek": "Sleep om te zoeken",
"Punctuation Delay": "Leesteken-vertraging",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Bevestig dat deze OPDS-verbinding via Readest-servers in de webapp wordt doorgestuurd voordat u doorgaat.",
"Custom Headers": "Aangepaste headers",
"Custom Headers (optional)": "Aangepaste headers (optioneel)",
"Add one header per line using \"Header-Name: value\".": "Voeg één header per regel toe met \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Ik begrijp dat deze OPDS-verbinding via Readest-servers in de webapp wordt doorgestuurd. Als ik Readest niet vertrouw met deze inloggegevens of headers, moet ik de native app gebruiken.",
"Unable to connect to Hardcover. Please check your network connection.": "Kan geen verbinding maken met Hardcover. Controleer uw netwerkverbinding.",
"Invalid Hardcover API token": "Ongeldig Hardcover API-token",
"Disconnected from Hardcover": "Verbinding met Hardcover verbroken",
"Hardcover Settings": "Hardcover-instellingen",
"Connected to Hardcover": "Verbonden met Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Verbind uw Hardcover-account om leesvoortgang en notities te synchroniseren.",
"Get your API token from hardcover.app → Settings → API.": "Haal uw API-token op via hardcover.app → Instellingen → API.",
"API Token": "API-token",
@@ -1398,5 +1386,34 @@
"Credentials": "Inloggegevens",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Tokens, gebruikersnamen en wachtwoorden voor OPDS, KOReader, Hardcover en Readwise. Wanneer uitgeschakeld blijven inloggegevens alleen op dit apparaat en worden ze nooit geüpload.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Gevoelige gesynchroniseerde velden worden vóór het uploaden versleuteld. Stel nu of later een wachtwoordzin in wanneer versleuteling nodig is.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Opgeslagen op dit account. Er wordt om gevraagd wanneer inloggegevens ontsleuteld moeten worden."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Opgeslagen op dit account. Er wordt om gevraagd wanneer inloggegevens ontsleuteld moeten worden.",
"Reading progress on this device differs from \"{{deviceName}}\".": "De leesvoortgang op dit apparaat verschilt van \"{{deviceName}}\".",
"This device": "Dit apparaat",
"Set up KOReader Sync…": "KOReader-synchronisatie instellen…",
"Set up Readwise…": "Readwise instellen…",
"Set up Hardcover…": "Hardcover instellen…",
"Red": "Rood",
"Yellow": "Geel",
"Green": "Groen",
"Blue": "Blauw",
"Violet": "Violet",
"Edit color": "Kleur bewerken",
"Add custom color": "Aangepaste kleur toevoegen",
"Add label": "Label toevoegen",
"Choose color": "Kleur kiezen",
"Connected to Hardcover. Last synced {{time}}.": "Verbonden met Hardcover. Laatst gesynchroniseerd {{time}}.",
"Integrations": "Integraties",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Verbonden met Readwise. Laatst gesynchroniseerd {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Blader door online catalogi en download boeken.",
"Connected as {{user}}": "Verbonden als {{user}}",
"Not connected": "Niet verbonden",
"{{count}} catalog_one": "{{count}} catalogus",
"{{count}} catalog_other": "{{count}} catalogi",
"No catalogs": "Geen catalogi",
"Connect Readest to external services for sync, highlights, and catalogs.": "Verbind Readest met externe diensten voor synchronisatie, markeringen en catalogi.",
"Reading Sync": "Leessynchronisatie",
"Content Sources": "Inhoudsbronnen",
"Scroll tabs": "Tabbladen scrollen"
}
@@ -468,7 +468,6 @@
"Remove from Cloud Only": "Usuń tylko z chmury",
"Remove from Device Only": "Usuń tylko z urządzenia",
"Disconnected": "Rozłączono",
"KOReader Sync Settings": "Ustawienia synchronizacji KOReader",
"Sync Strategy": "Strategia synchronizacji",
"Ask on conflict": "Pytaj w przypadku konfliktu",
"Always use latest": "Zawsze używaj najnowszej",
@@ -486,10 +485,7 @@
"Connect": "Połącz",
"KOReader Sync": "Synchronizacja KOReader",
"Sync Conflict": "Konflikt synchronizacji",
"Sync reading progress from \"{{deviceName}}\"?": "Synchronizować postęp czytania z \"{{deviceName}}\"?",
"another device": "inne urządzenie",
"Local Progress": "Postęp lokalny",
"Remote Progress": "Postęp zdalny",
"Page {{page}} of {{total}} ({{percentage}}%)": "Strona {{page}} z {{total}} ({{percentage}}%)",
"Current position": "Bieżąca pozycja",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Około strona {{page}} z {{total}} ({{percentage}}%)",
@@ -642,7 +638,6 @@
"Strikethrough": "Przekreślenie",
"Squiggly": "Falista linia",
"Outline": "Obrys",
"Save Current Color": "Zapisz bieżący kolor",
"Quick Colors": "Szybkie kolory",
"Highlighter": "Zakreślacz",
"Save Book Cover": "Zapisz okładkę książki",
@@ -1058,16 +1053,12 @@
"Invalid Readwise access token": "Nieprawidłowy token dostępu Readwise",
"Disconnected from Readwise": "Rozłączono z Readwise",
"Never": "Nigdy",
"Readwise Settings": "Ustawienia Readwise",
"Connected to Readwise": "Połączono z Readwise",
"Last synced: {{time}}": "Ostatnia synchronizacja: {{time}}",
"Sync Enabled": "Synchronizacja włączona",
"Disconnect": "Rozłącz",
"Connect your Readwise account to sync highlights.": "Połącz swoje konto Readwise, aby synchronizować wyróżnienia.",
"Get your access token at": "Pobierz token dostępu pod adresem",
"Access Token": "Token dostępu",
"Paste your Readwise access token": "Wklej swój token dostępu Readwise",
"Config": "Konfiguracja",
"Readwise Sync": "Synchronizacja Readwise",
"Push Highlights": "Wyślij wyróżnienia",
"Highlights synced to Readwise": "Wyróżnienia zsynchronizowane z Readwise",
@@ -1175,15 +1166,12 @@
"Drag to seek": "Przeciągnij, aby przewinąć",
"Punctuation Delay": "Opóźnienie interpunkcji",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Potwierdź, że to połączenie OPDS będzie przekierowywane przez serwery Readest w aplikacji webowej, zanim przejdziesz dalej.",
"Custom Headers": "Niestandardowe nagłówki",
"Custom Headers (optional)": "Niestandardowe nagłówki (opcjonalnie)",
"Add one header per line using \"Header-Name: value\".": "Dodaj jeden nagłówek w każdym wierszu, używając formatu \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Rozumiem, że to połączenie OPDS będzie przekierowywane przez serwery Readest w aplikacji webowej. Jeśli nie ufam Readest w kwestii tych danych uwierzytelniających lub nagłówków, powinienem użyć aplikacji natywnej.",
"Unable to connect to Hardcover. Please check your network connection.": "Nie można połączyć się z Hardcover. Sprawdź połączenie sieciowe.",
"Invalid Hardcover API token": "Nieprawidłowy token API Hardcover",
"Disconnected from Hardcover": "Rozłączono z Hardcover",
"Hardcover Settings": "Ustawienia Hardcover",
"Connected to Hardcover": "Połączono z Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Połącz swoje konto Hardcover, aby synchronizować postęp czytania i notatki.",
"Get your API token from hardcover.app → Settings → API.": "Pobierz token API z hardcover.app → Ustawienia → API.",
"API Token": "Token API",
@@ -1446,5 +1434,36 @@
"Credentials": "Poświadczenia",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Tokeny, nazwy użytkowników i hasła do OPDS, KOReader, Hardcover i Readwise. Gdy wyłączone, poświadczenia pozostają tylko na tym urządzeniu i nigdy nie są przesyłane.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Wrażliwe zsynchronizowane pola są szyfrowane przed wysłaniem. Ustaw hasło teraz lub później, gdy szyfrowanie będzie potrzebne.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Zapisane w tym koncie. Zostaniesz o nie poproszony przy odszyfrowywaniu poświadczeń."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Zapisane w tym koncie. Zostaniesz o nie poproszony przy odszyfrowywaniu poświadczeń.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Postęp czytania na tym urządzeniu różni się od „{{deviceName}}”.",
"This device": "To urządzenie",
"Set up KOReader Sync…": "Skonfiguruj synchronizację KOReader…",
"Set up Readwise…": "Skonfiguruj Readwise…",
"Set up Hardcover…": "Skonfiguruj Hardcover…",
"Red": "Czerwony",
"Yellow": "Żółty",
"Green": "Zielony",
"Blue": "Niebieski",
"Violet": "Fioletowy",
"Edit color": "Edytuj kolor",
"Add custom color": "Dodaj kolor niestandardowy",
"Add label": "Dodaj etykietę",
"Choose color": "Wybierz kolor",
"Connected to Hardcover. Last synced {{time}}.": "Połączono z Hardcover. Ostatnia synchronizacja {{time}}.",
"Integrations": "Integracje",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Połączono z Readwise. Ostatnia synchronizacja {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Przeglądaj i pobieraj książki z katalogów online.",
"Connected as {{user}}": "Połączono jako {{user}}",
"Not connected": "Nie połączono",
"{{count}} catalog_one": "{{count}} katalog",
"{{count}} catalog_few": "{{count}} katalogi",
"{{count}} catalog_many": "{{count}} katalogów",
"{{count}} catalog_other": "{{count}} katalogu",
"No catalogs": "Brak katalogów",
"Connect Readest to external services for sync, highlights, and catalogs.": "Połącz Readest z usługami zewnętrznymi w celu synchronizacji, podświetleń i katalogów.",
"Reading Sync": "Synchronizacja czytania",
"Content Sources": "Źródła treści",
"Scroll tabs": "Przewiń karty"
}
@@ -283,7 +283,6 @@
"Edit": "Editar",
"Remove": "Remover",
"Username": "Nome de usuário",
"Custom Headers": "Cabeçalhos personalizados",
"Auto-download": "Download automático",
"Last synced {{when}}": "Última sincronização {{when}}",
"{{count}} failed_one": "{{count}} falhou",
@@ -508,9 +507,6 @@
"Invalid Hardcover API token": "Token de API do Hardcover inválido",
"Disconnected from Hardcover": "Desconectado do Hardcover",
"Never": "Nunca",
"Hardcover Settings": "Configurações do Hardcover",
"Connected to Hardcover": "Conectado ao Hardcover",
"Last synced: {{time}}": "Última sincronização: {{time}}",
"Sync Enabled": "Sincronização ativada",
"Disconnect": "Desconectar",
"Connect your Hardcover account to sync reading progress and notes.": "Conecte sua conta Hardcover para sincronizar o progresso de leitura e as notas.",
@@ -530,13 +526,9 @@
"Zoomed": "Com zoom",
"Zoom level": "Nível de zoom",
"Sync Conflict": "Conflito de sincronização",
"Sync reading progress from \"{{deviceName}}\"?": "Sincronizar progresso de leitura de \"{{deviceName}}\"?",
"another device": "outro dispositivo",
"Local Progress": "Progresso local",
"Remote Progress": "Progresso remoto",
"Failed to connect": "Falha ao conectar",
"Disconnected": "Desconectado",
"KOReader Sync Settings": "Configurações de sincronização KOReader",
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
"Sync Server Connected": "Servidor de sincronização conectado",
"Sync Strategy": "Estratégia de sincronização",
@@ -594,8 +586,6 @@
"Unable to connect to Readwise. Please check your network connection.": "Não foi possível conectar ao Readwise. Verifique sua conexão de rede.",
"Invalid Readwise access token": "Token de acesso do Readwise inválido",
"Disconnected from Readwise": "Desconectado do Readwise",
"Readwise Settings": "Configurações do Readwise",
"Connected to Readwise": "Conectado ao Readwise",
"Connect your Readwise account to sync highlights.": "Conecte sua conta do Readwise para sincronizar os destaques.",
"Get your access token at": "Obtenha seu token de acesso em",
"Access Token": "Token de acesso",
@@ -653,7 +643,6 @@
"Exit Parallel Read": "Sair da leitura paralela",
"Enter Parallel Read": "Entrar na leitura paralela",
"KOReader Sync": "KOReader Sync",
"Config": "Configuração",
"Push Progress": "Enviar progresso",
"Pull Progress": "Obter progresso",
"Readwise Sync": "Sincronização com o Readwise",
@@ -1047,7 +1036,6 @@
"Strikethrough": "Tachado",
"Squiggly": "Ondulado",
"Outline": "Contorno",
"Save Current Color": "Salvar cor atual",
"Quick Colors": "Cores rápidas",
"Override Book Color": "Substituir cor do livro",
"None": "Nenhum",
@@ -1422,5 +1410,35 @@
"Credentials": "Credenciais",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Tokens, nomes de usuário e senhas para OPDS, KOReader, Hardcover e Readwise. Quando desativado, as credenciais permanecem apenas neste dispositivo e nunca são enviadas.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Os campos sincronizados sensíveis são criptografados antes do envio. Defina uma senha agora ou mais tarde, quando a criptografia for necessária.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Salva nesta conta. Você será solicitado a informá-la quando precisar descriptografar credenciais."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Salva nesta conta. Você será solicitado a informá-la quando precisar descriptografar credenciais.",
"Reading progress on this device differs from \"{{deviceName}}\".": "O progresso de leitura neste dispositivo difere do de \"{{deviceName}}\".",
"This device": "Este dispositivo",
"Set up KOReader Sync…": "Configurar sincronização com KOReader…",
"Set up Readwise…": "Configurar Readwise…",
"Set up Hardcover…": "Configurar Hardcover…",
"Red": "Vermelho",
"Yellow": "Amarelo",
"Green": "Verde",
"Blue": "Azul",
"Violet": "Violeta",
"Edit color": "Editar cor",
"Add custom color": "Adicionar cor personalizada",
"Add label": "Adicionar rótulo",
"Choose color": "Escolher cor",
"Connected to Hardcover. Last synced {{time}}.": "Conectado ao Hardcover. Última sincronização {{time}}.",
"Integrations": "Integrações",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Conectado ao Readwise. Última sincronização {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Procure e baixe livros de catálogos online.",
"Connected as {{user}}": "Conectado como {{user}}",
"Not connected": "Não conectado",
"{{count}} catalog_one": "{{count}} catálogo",
"{{count}} catalog_many": "{{count}} catálogos",
"{{count}} catalog_other": "{{count}} catálogos",
"No catalogs": "Nenhum catálogo",
"Connect Readest to external services for sync, highlights, and catalogs.": "Conecte o Readest a serviços externos para sincronização, destaques e catálogos.",
"Reading Sync": "Sincronização de leitura",
"Content Sources": "Fontes de conteúdo",
"Scroll tabs": "Rolar abas"
}
@@ -464,7 +464,6 @@
"Remove from Cloud Only": "Remover apenas da Nuvem",
"Remove from Device Only": "Remover apenas do Dispositivo",
"Disconnected": "Desconectado",
"KOReader Sync Settings": "Configurações de Sincronização KOReader",
"Sync Strategy": "Estratégia de Sincronização",
"Ask on conflict": "Perguntar em caso de conflito",
"Always use latest": "Sempre usar o mais recente",
@@ -482,10 +481,7 @@
"Connect": "Conectar",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Conflito de Sincronização",
"Sync reading progress from \"{{deviceName}}\"?": "Sincronizar progresso de leitura de \"{{deviceName}}\"?",
"another device": "outro dispositivo",
"Local Progress": "Progresso Local",
"Remote Progress": "Progresso Remoto",
"Page {{page}} of {{total}} ({{percentage}}%)": "Página {{page}} de {{total}} ({{percentage}}%)",
"Current position": "Posição Atual",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Aproximadamente página {{page}} de {{total}} ({{percentage}}%)",
@@ -638,7 +634,6 @@
"Strikethrough": "Tachado",
"Squiggly": "Ondulado",
"Outline": "Contorno",
"Save Current Color": "Salvar cor atual",
"Quick Colors": "Cores rápidas",
"Highlighter": "Marcador",
"Save Book Cover": "Salvar Capa do Livro",
@@ -1046,16 +1041,12 @@
"Invalid Readwise access token": "Token de acesso do Readwise inválido",
"Disconnected from Readwise": "Desconectado do Readwise",
"Never": "Nunca",
"Readwise Settings": "Configurações do Readwise",
"Connected to Readwise": "Conectado ao Readwise",
"Last synced: {{time}}": "Última sincronização: {{time}}",
"Sync Enabled": "Sincronização ativada",
"Disconnect": "Desconectar",
"Connect your Readwise account to sync highlights.": "Conecte sua conta do Readwise para sincronizar os destaques.",
"Get your access token at": "Obtenha seu token de acesso em",
"Access Token": "Token de acesso",
"Paste your Readwise access token": "Cole seu token de acesso do Readwise",
"Config": "Configuração",
"Readwise Sync": "Sincronização com o Readwise",
"Push Highlights": "Enviar destaques",
"Highlights synced to Readwise": "Destaques sincronizados com o Readwise",
@@ -1162,15 +1153,12 @@
"Drag to seek": "Arraste para procurar",
"Punctuation Delay": "Atraso de pontuação",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Confirme que esta conexão OPDS será encaminhada através dos servidores Readest no aplicativo web antes de continuar.",
"Custom Headers": "Cabeçalhos personalizados",
"Custom Headers (optional)": "Cabeçalhos personalizados (opcional)",
"Add one header per line using \"Header-Name: value\".": "Adicione um cabeçalho por linha usando \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Entendo que esta conexão OPDS será encaminhada através dos servidores Readest no aplicativo web. Se não confio no Readest com essas credenciais ou cabeçalhos, devo usar o aplicativo nativo.",
"Unable to connect to Hardcover. Please check your network connection.": "Não foi possível conectar ao Hardcover. Verifique sua conexão de rede.",
"Invalid Hardcover API token": "Token de API do Hardcover inválido",
"Disconnected from Hardcover": "Desconectado do Hardcover",
"Hardcover Settings": "Configurações do Hardcover",
"Connected to Hardcover": "Conectado ao Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Conecte sua conta Hardcover para sincronizar o progresso de leitura e notas.",
"Get your API token from hardcover.app → Settings → API.": "Obtenha seu token de API em hardcover.app → Configurações → API.",
"API Token": "Token de API",
@@ -1422,5 +1410,35 @@
"Credentials": "Credenciais",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Tokens, nomes de utilizador e palavras-passe para OPDS, KOReader, Hardcover e Readwise. Quando desativado, as credenciais permanecem apenas neste dispositivo e nunca são enviadas.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Os campos sincronizados sensíveis são cifrados antes do envio. Defina uma frase-senha agora ou mais tarde, quando a cifragem for necessária.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Guardada nesta conta. Ser-lhe-á pedida quando precisar de decifrar credenciais."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Guardada nesta conta. Ser-lhe-á pedida quando precisar de decifrar credenciais.",
"Reading progress on this device differs from \"{{deviceName}}\".": "O progresso de leitura neste dispositivo difere do «{{deviceName}}».",
"This device": "Este dispositivo",
"Set up KOReader Sync…": "Configurar sincronização com KOReader…",
"Set up Readwise…": "Configurar Readwise…",
"Set up Hardcover…": "Configurar Hardcover…",
"Red": "Vermelho",
"Yellow": "Amarelo",
"Green": "Verde",
"Blue": "Azul",
"Violet": "Violeta",
"Edit color": "Editar cor",
"Add custom color": "Adicionar cor personalizada",
"Add label": "Adicionar etiqueta",
"Choose color": "Escolher cor",
"Connected to Hardcover. Last synced {{time}}.": "Ligado ao Hardcover. Última sincronização {{time}}.",
"Integrations": "Integrações",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Ligado ao Readwise. Última sincronização {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Explore e descarregue livros de catálogos online.",
"Connected as {{user}}": "Ligado como {{user}}",
"Not connected": "Não ligado",
"{{count}} catalog_one": "{{count}} catálogo",
"{{count}} catalog_many": "{{count}} catálogos",
"{{count}} catalog_other": "{{count}} catálogos",
"No catalogs": "Sem catálogos",
"Connect Readest to external services for sync, highlights, and catalogs.": "Liga o Readest a serviços externos para sincronização, destaques e catálogos.",
"Reading Sync": "Sincronização de leitura",
"Content Sources": "Fontes de conteúdo",
"Scroll tabs": "Deslocar separadores"
}
@@ -454,13 +454,9 @@
"Zoomed": "Mărit",
"Zoom level": "Nivel de zoom",
"Sync Conflict": "Conflict de sincronizare",
"Sync reading progress from \"{{deviceName}}\"?": "Sincronizați progresul citirii de pe „{{deviceName}}”?",
"another device": "alt dispozitiv",
"Local Progress": "Progres local",
"Remote Progress": "Progres la distanță",
"Failed to connect": "Nu s-a putut conecta",
"Disconnected": "Deconectat",
"KOReader Sync Settings": "Setări de sincronizare KOReader",
"Sync as {{userDisplayName}}": "Sincronizare ca {{userDisplayName}}",
"Sync Server Connected": "Server de sincronizare conectat",
"Sync Strategy": "Strategie de sincronizare",
@@ -521,9 +517,6 @@
"Invalid Readwise access token": "Token de acces Readwise nevalid",
"Disconnected from Readwise": "Deconectat de la Readwise",
"Never": "Niciodată",
"Readwise Settings": "Setări Readwise",
"Connected to Readwise": "Conectat la Readwise",
"Last synced: {{time}}": "Ultima sincronizare: {{time}}",
"Sync Enabled": "Sincronizare activată",
"Disconnect": "Deconectează",
"Connect your Readwise account to sync highlights.": "Conectați-vă contul Readwise pentru a sincroniza evidențierile.",
@@ -577,7 +570,6 @@
"Exit Parallel Read": "Ieși din citirea paralelă",
"Enter Parallel Read": "Intră în citirea paralelă",
"KOReader Sync": "Sincronizare KOReader",
"Config": "Configurare",
"Push Progress": "Trimite progresul (Push)",
"Pull Progress": "Preia progresul (Pull)",
"Readwise Sync": "Sincronizare Readwise",
@@ -912,7 +904,6 @@
"Strikethrough": "Tăiat",
"Squiggly": "Ondulat",
"Outline": "Contur",
"Save Current Color": "Salvează culoarea curentă",
"Quick Colors": "Culori rapide",
"Override Book Color": "Suprascrie culoarea cărții",
"None": "Niciuna",
@@ -1162,15 +1153,12 @@
"Drag to seek": "Trageți pentru a căuta",
"Punctuation Delay": "Întârziere punctuație",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Confirmați că această conexiune OPDS va fi direcționată prin serverele Readest din aplicația web înainte de a continua.",
"Custom Headers": "Anteturi personalizate",
"Custom Headers (optional)": "Anteturi personalizate (opțional)",
"Add one header per line using \"Header-Name: value\".": "Adăugați un antet pe linie folosind \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Înțeleg că această conexiune OPDS va fi direcționată prin serverele Readest din aplicația web. Dacă nu am încredere în Readest cu aceste credențiale sau anteturi, ar trebui să folosesc aplicația nativă.",
"Unable to connect to Hardcover. Please check your network connection.": "Nu se poate conecta la Hardcover. Verificați conexiunea la rețea.",
"Invalid Hardcover API token": "Token API Hardcover invalid",
"Disconnected from Hardcover": "Deconectat de la Hardcover",
"Hardcover Settings": "Setări Hardcover",
"Connected to Hardcover": "Conectat la Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Conectați contul Hardcover pentru a sincroniza progresul lecturii și notele.",
"Get your API token from hardcover.app → Settings → API.": "Obțineți tokenul API de la hardcover.app → Setări → API.",
"API Token": "Token API",
@@ -1422,5 +1410,35 @@
"Credentials": "Acreditări",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Token-uri, nume de utilizator și parole pentru OPDS, KOReader, Hardcover și Readwise. Când sunt dezactivate, acreditările rămân doar pe acest dispozitiv și nu sunt niciodată încărcate.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Câmpurile sincronizate sensibile sunt criptate înainte de încărcare. Setați o frază de acces acum sau mai târziu, când criptarea va fi necesară.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Salvată în acest cont. Vi se va cere când va trebui să se decripteze acreditările."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Salvată în acest cont. Vi se va cere când va trebui să se decripteze acreditările.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Progresul de citire pe acest dispozitiv diferă de „{{deviceName}}”.",
"This device": "Acest dispozitiv",
"Set up KOReader Sync…": "Configurează sincronizarea KOReader…",
"Set up Readwise…": "Configurează Readwise…",
"Set up Hardcover…": "Configurează Hardcover…",
"Red": "Roșu",
"Yellow": "Galben",
"Green": "Verde",
"Blue": "Albastru",
"Violet": "Violet",
"Edit color": "Editează culoarea",
"Add custom color": "Adaugă culoare personalizată",
"Add label": "Adaugă etichetă",
"Choose color": "Alege culoarea",
"Connected to Hardcover. Last synced {{time}}.": "Conectat la Hardcover. Ultima sincronizare {{time}}.",
"Integrations": "Integrări",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Conectat la Readwise. Ultima sincronizare {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Răsfoiește și descarcă cărți din cataloagele online.",
"Connected as {{user}}": "Conectat ca {{user}}",
"Not connected": "Neconectat",
"{{count}} catalog_one": "{{count}} catalog",
"{{count}} catalog_few": "{{count}} cataloage",
"{{count}} catalog_other": "{{count}} de cataloage",
"No catalogs": "Niciun catalog",
"Connect Readest to external services for sync, highlights, and catalogs.": "Conectează Readest la servicii externe pentru sincronizare, evidențieri și cataloage.",
"Reading Sync": "Sincronizare citire",
"Content Sources": "Surse de conținut",
"Scroll tabs": "Derulează filele"
}
@@ -468,7 +468,6 @@
"Remove from Cloud Only": "Удалить только из облака",
"Remove from Device Only": "Удалить только с устройства",
"Disconnected": "Отключено",
"KOReader Sync Settings": "Настройки синхронизации KOReader",
"Sync Strategy": "Стратегия синхронизации",
"Ask on conflict": "Спрашивать при конфликте",
"Always use latest": "Всегда использовать последнюю",
@@ -486,10 +485,7 @@
"Connect": "Подключиться",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "Конфликт синхронизации",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронизировать прогресс чтения с \"{{deviceName}}\"?",
"another device": "другом устройстве",
"Local Progress": "Локальный прогресс",
"Remote Progress": "Удаленный прогресс",
"Page {{page}} of {{total}} ({{percentage}}%)": "Страница {{page}} из {{total}} ({{percentage}}%)",
"Current position": "Текущая позиция",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизительно страница {{page}} из {{total}} ({{percentage}}%)",
@@ -642,7 +638,6 @@
"Strikethrough": "Зачёркивание",
"Squiggly": "Волнистая линия",
"Outline": "Контур",
"Save Current Color": "Сохранить текущий цвет",
"Quick Colors": "Быстрые цвета",
"Highlighter": "Маркер",
"Save Book Cover": "Сохранить обложку книги",
@@ -1058,16 +1053,12 @@
"Invalid Readwise access token": "Недействительный токен доступа Readwise",
"Disconnected from Readwise": "Отключено от Readwise",
"Never": "Никогда",
"Readwise Settings": "Настройки Readwise",
"Connected to Readwise": "Подключено к Readwise",
"Last synced: {{time}}": "Последняя синхронизация: {{time}}",
"Sync Enabled": "Синхронизация включена",
"Disconnect": "Отключить",
"Connect your Readwise account to sync highlights.": "Подключите ваш аккаунт Readwise для синхронизации выделений.",
"Get your access token at": "Получите ваш токен доступа на",
"Access Token": "Токен доступа",
"Paste your Readwise access token": "Вставьте ваш токен доступа Readwise",
"Config": "Конфигурация",
"Readwise Sync": "Синхронизация Readwise",
"Push Highlights": "Отправить выделения",
"Highlights synced to Readwise": "Выделения синхронизированы с Readwise",
@@ -1175,15 +1166,12 @@
"Drag to seek": "Перетащите для перемотки",
"Punctuation Delay": "Задержка на знаках препинания",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Подтвердите, что это OPDS-соединение будет проксироваться через серверы Readest в веб-приложении, прежде чем продолжить.",
"Custom Headers": "Пользовательские заголовки",
"Custom Headers (optional)": "Пользовательские заголовки (необязательно)",
"Add one header per line using \"Header-Name: value\".": "Добавляйте по одному заголовку на строку в формате \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Я понимаю, что это OPDS-соединение будет проксироваться через серверы Readest в веб-приложении. Если я не доверяю Readest свои учётные данные или заголовки, мне следует использовать нативное приложение.",
"Unable to connect to Hardcover. Please check your network connection.": "Не удалось подключиться к Hardcover. Проверьте сетевое подключение.",
"Invalid Hardcover API token": "Недействительный API-токен Hardcover",
"Disconnected from Hardcover": "Отключено от Hardcover",
"Hardcover Settings": "Настройки Hardcover",
"Connected to Hardcover": "Подключено к Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Подключите свой аккаунт Hardcover для синхронизации прогресса чтения и заметок.",
"Get your API token from hardcover.app → Settings → API.": "Получите API-токен на hardcover.app → Настройки → API.",
"API Token": "API-токен",
@@ -1446,5 +1434,36 @@
"Credentials": "Учётные данные",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Токены, имена пользователей и пароли для OPDS, KOReader, Hardcover и Readwise. Если отключено, учётные данные остаются только на этом устройстве и никогда не отправляются.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Конфиденциальные синхронизируемые поля шифруются перед загрузкой. Задайте парольную фразу сейчас или позже, когда понадобится шифрование.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Сохранено в этой учётной записи. Вы получите запрос при расшифровке учётных данных."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Сохранено в этой учётной записи. Вы получите запрос при расшифровке учётных данных.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Прогресс чтения на этом устройстве отличается от «{{deviceName}}».",
"This device": "Это устройство",
"Set up KOReader Sync…": "Настроить синхронизацию KOReader…",
"Set up Readwise…": "Настроить Readwise…",
"Set up Hardcover…": "Настроить Hardcover…",
"Red": "Красный",
"Yellow": "Жёлтый",
"Green": "Зелёный",
"Blue": "Синий",
"Violet": "Фиолетовый",
"Edit color": "Изменить цвет",
"Add custom color": "Добавить свой цвет",
"Add label": "Добавить метку",
"Choose color": "Выбрать цвет",
"Connected to Hardcover. Last synced {{time}}.": "Подключено к Hardcover. Последняя синхронизация {{time}}.",
"Integrations": "Интеграции",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Подключено к Readwise. Последняя синхронизация {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Просматривайте и скачивайте книги из онлайн-каталогов.",
"Connected as {{user}}": "Подключено как {{user}}",
"Not connected": "Не подключено",
"{{count}} catalog_one": "{{count}} каталог",
"{{count}} catalog_few": "{{count}} каталога",
"{{count}} catalog_many": "{{count}} каталогов",
"{{count}} catalog_other": "{{count}} каталога",
"No catalogs": "Нет каталогов",
"Connect Readest to external services for sync, highlights, and catalogs.": "Подключите Readest к внешним сервисам для синхронизации, выделений и каталогов.",
"Reading Sync": "Синхронизация чтения",
"Content Sources": "Источники контента",
"Scroll tabs": "Прокрутить вкладки"
}
@@ -155,13 +155,9 @@
"Small": "කුඩා",
"Large": "විශාල",
"Sync Conflict": "සමමුහුර්ත ගැටුම",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" වෙතින් කියවීමේ ප්‍රගතිය සමමුහුර්ත කරන්නද?",
"another device": "වෙනත් උපකරණයක්",
"Local Progress": "දේශීය ප්‍රගතිය",
"Remote Progress": "දුරස්ථ ප්‍රගතිය",
"Failed to connect": "සම්බන්ධ වීමට අසමත්",
"Disconnected": "විසන්ධි වුණි",
"KOReader Sync Settings": "KOReader සමමුහුර්ත සැකසුම්",
"Sync as {{userDisplayName}}": "{{userDisplayName}} ලෙස සමමුහුර්ත කරන්න",
"Sync Server Connected": "සමමුහුර්ත සේවාදායකය සම්බන්ධ විය",
"Sync Strategy": "සමමුහුර්ත උපාය",
@@ -634,7 +630,6 @@
"Strikethrough": "ඇඳුම",
"Squiggly": "වැලි රේඛාව",
"Outline": "පරිසීමාව",
"Save Current Color": "දැන් ඇති වර්ණය සුරකින්න",
"Quick Colors": "ක්ෂණික වර්ණ",
"Highlighter": "ඉස්මතු කරන්නා",
"Save Book Cover": "පොත් ආවරණය සුරකින්න",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "අවලංගු Readwise ප්‍රවේශ ටෝකනය",
"Disconnected from Readwise": "Readwise වෙතින් විසන්ධි විය",
"Never": "කවදාවත් නැහැ",
"Readwise Settings": "Readwise සැකසුම්",
"Connected to Readwise": "Readwise වෙත සම්බන්ධ විය",
"Last synced: {{time}}": "අවසානයට සමමුහුර්ත කළේ: {{time}}",
"Sync Enabled": "සමමුහුර්ත කිරීම සබල කර ඇත",
"Disconnect": "විසන්ධි කරන්න",
"Connect your Readwise account to sync highlights.": "විශේෂ අවස්ථා සමමුහුර්ත කිරීමට ඔබගේ Readwise ගිණුම සම්බන්ධ කරන්න.",
"Get your access token at": "ඔබගේ ප්‍රවේශ ටෝකනය මෙතැනින් ලබා ගන්න",
"Access Token": "ප්‍රවේශ ටෝකනය",
"Paste your Readwise access token": "ඔබගේ Readwise ප්‍රවේශ ටෝකනය මෙහි අලවන්න",
"Config": "වින්‍යාසය",
"Readwise Sync": "Readwise සමමුහුර්තකරණය",
"Push Highlights": "විශේෂ අවස්ථා යොමු කරන්න",
"Highlights synced to Readwise": "විශේෂ අවස්ථා Readwise සමඟ සමමුහුර්ත විය",
@@ -1149,15 +1140,12 @@
"Drag to seek": "සෙවීමට ඇදගන්න",
"Punctuation Delay": "විරාම ලකුණු ප්‍රමාදය",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "ඉදිරියට යාමට පෙර මෙම OPDS සම්බන්ධතාවය වෙබ් යෙදුමේ Readest සේවාදායක හරහා ප්‍රොක්සි කරන බව තහවුරු කරන්න.",
"Custom Headers": "අභිරුචි ශීර්ෂ",
"Custom Headers (optional)": "අභිරුචි ශීර්ෂ (විකල්ප)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" භාවිතයෙන් පේළියකට එක ශීර්ෂයක් එක් කරන්න.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "මෙම OPDS සම්බන්ධතාවය වෙබ් යෙදුමේ Readest සේවාදායක හරහා ප්‍රොක්සි කරන බව මට තේරෙනවා. මෙම අක්තපත්‍ර හෝ ශීර්ෂ සමඟ Readest විශ්වාස නොකරන්නේ නම්, මම ස්වදේශීය යෙදුම භාවිතා කළ යුතුය.",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover වෙත සම්බන්ධ විය නොහැක. ඔබේ ජාල සම්බන්ධතාවය පරීක්ෂා කරන්න.",
"Invalid Hardcover API token": "අවලංගු Hardcover API ටෝකනය",
"Disconnected from Hardcover": "Hardcover වෙතින් විසන්ධි විය",
"Hardcover Settings": "Hardcover සැකසීම්",
"Connected to Hardcover": "Hardcover වෙත සම්බන්ධයි",
"Connect your Hardcover account to sync reading progress and notes.": "කියවීමේ ප්‍රගතිය සහ සටහන් සමමුහුර්ත කිරීමට ඔබේ Hardcover ගිණුම සම්බන්ධ කරන්න.",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → සැකසීම් → API වෙතින් ඔබේ API ටෝකනය ලබාගන්න.",
"API Token": "API ටෝකනය",
@@ -1398,5 +1386,34 @@
"Credentials": "අක්තපත්‍ර",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS, KOReader, Hardcover, සහ Readwise සඳහා ටෝකන, පරිශීලක නාම සහ මුරපද. අක්‍රිය කළ විට, අක්තපත්‍ර මෙම උපාංගයේ පමණක් රැඳී පවතින අතර කිසිදා උඩුගත නොකෙරේ.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "සංවේදී සමමුහූර්ත ක්ෂේත්‍ර උඩුගත කිරීමට පෙර සංකේතනය වේ. සංකේතනය අවශ්‍ය වූ විට දැන් හෝ පසුව මුර වැකියක් සකසන්න.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "මෙම ගිණුමට සුරකින ලදී. අක්තපත්‍ර විකේතනය කිරීමේදී ඔබෙන් එය ඉල්ලනු ලැබේ."
"Saved to this account. You will be prompted for it when decrypting credentials.": "මෙම ගිණුමට සුරකින ලදී. අක්තපත්‍ර විකේතනය කිරීමේදී ඔබෙන් එය ඉල්ලනු ලැබේ.",
"Reading progress on this device differs from \"{{deviceName}}\".": "මෙම උපාංගයේ කියවීමේ ප්‍රගතිය \"{{deviceName}}\" වලින් වෙනස් වේ.",
"This device": "මෙම උපාංගය",
"Set up KOReader Sync…": "KOReader සමමුහුර්ත කිරීම සකසන්න…",
"Set up Readwise…": "Readwise සකසන්න…",
"Set up Hardcover…": "Hardcover සකසන්න…",
"Red": "රතු",
"Yellow": "කහ",
"Green": "කොළ",
"Blue": "නිල්",
"Violet": "දම්",
"Edit color": "වර්ණය සංස්කරණය කරන්න",
"Add custom color": "අභිමත වර්ණයක් එක් කරන්න",
"Add label": "ලේබලයක් එක් කරන්න",
"Choose color": "වර්ණයක් තෝරන්න",
"Connected to Hardcover. Last synced {{time}}.": "Hardcover වෙත සම්බන්ධිතයි. අවසන් සමමුහුර්ත කිරීම {{time}}.",
"Integrations": "ඒකාබද්ධ කිරීම්",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwise වෙත සම්බන්ධිතයි. අවසන් සමමුහුර්ත කිරීම {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "ඔන්ලයින් නාමාවලි වලින් පොත් පිරික්සා බාගත කරන්න.",
"Connected as {{user}}": "{{user}} ලෙස සම්බන්ධිතයි",
"Not connected": "සම්බන්ධ වී නැත",
"{{count}} catalog_one": "නාමාවලි {{count}}",
"{{count}} catalog_other": "නාමාවලි {{count}}",
"No catalogs": "නාමාවලි නැත",
"Connect Readest to external services for sync, highlights, and catalogs.": "සමමුහුර්ත කිරීම, ඉස්මතු කිරීම් සහ නාමාවලි සඳහා Readest බාහිර සේවාවන්ට සම්බන්ධ කරන්න.",
"Reading Sync": "කියවීමේ සමමුහුර්ත කිරීම",
"Content Sources": "අන්තර්ගත මූලාශ්‍ර",
"Scroll tabs": "ටැබ් අනුචලනය කරන්න"
}
@@ -430,13 +430,9 @@
"Zoomed": "Povečano",
"Zoom level": "Raven povečave",
"Sync Conflict": "Spor pri sinhronizaciji",
"Sync reading progress from \"{{deviceName}}\"?": "Želite sinhronizirati napredek branja iz naprave \"{{deviceName}}\"?",
"another device": "druga naprava",
"Local Progress": "Lokalni napredek",
"Remote Progress": "Oddaljeni napredek",
"Failed to connect": "Povezava ni uspela",
"Disconnected": "Povezava prekinjena",
"KOReader Sync Settings": "Nastavitve KOReader Sync",
"Sync as {{userDisplayName}}": "Sinhroniziraj kot {{userDisplayName}}",
"Sync Server Connected": "Strežnik za sinhronizacijo povezan",
"Sync Strategy": "Strategija sinhronizacije",
@@ -498,9 +494,6 @@
"Invalid Readwise access token": "Neveljaven žeton za dostop do Readwise",
"Disconnected from Readwise": "Povezava z Readwise prekinjena",
"Never": "Nikoli",
"Readwise Settings": "Nastavitve Readwise",
"Connected to Readwise": "Povezano z Readwise",
"Last synced: {{time}}": "Zadnja sinhronizacija: {{time}}",
"Sync Enabled": "Sinhronizacija omogočena",
"Disconnect": "Prekini povezavo",
"Connect your Readwise account to sync highlights.": "Povežite svoj Readwise račun za sinhronizacijo označb.",
@@ -547,7 +540,6 @@
"Exit Parallel Read": "Zapusti vzporedno branje",
"Enter Parallel Read": "Vstopi v vzporedno branje",
"KOReader Sync": "KOReader Sync",
"Config": "Konfiguracija",
"Push Progress": "Pošlji napredek",
"Pull Progress": "Prejmi napredek",
"Readwise Sync": "Readwise Sync",
@@ -885,7 +877,6 @@
"Strikethrough": "Prečrtano",
"Squiggly": "Vijugasto",
"Outline": "Obroba",
"Save Current Color": "Shrani trenutno barvo",
"Quick Colors": "Hitre barve",
"Override Book Color": "Prezri barvo knjige",
"None": "Brez",
@@ -1175,15 +1166,12 @@
"Drag to seek": "Povlecite za iskanje",
"Punctuation Delay": "Zamik ločil",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Pred nadaljevanjem potrdite, da bo ta povezava OPDS preusmerjena prek strežnikov Readest v spletni aplikaciji.",
"Custom Headers": "Glave po meri",
"Custom Headers (optional)": "Glave po meri (neobvezno)",
"Add one header per line using \"Header-Name: value\".": "Dodajte eno glavo na vrstico v obliki \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Razumem, da bo ta povezava OPDS preusmerjena prek strežnikov Readest v spletni aplikaciji. Če ne zaupam Readest s temi poverilnicami ali glavami, moram uporabiti izvorno aplikacijo.",
"Unable to connect to Hardcover. Please check your network connection.": "Povezava s Hardcover ni mogoča. Preverite omrežno povezavo.",
"Invalid Hardcover API token": "Neveljaven žeton API za Hardcover",
"Disconnected from Hardcover": "Prekinjeno s Hardcover",
"Hardcover Settings": "Nastavitve Hardcover",
"Connected to Hardcover": "Povezano s Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Povežite svoj račun Hardcover za sinhronizacijo napredka branja in zapiskov.",
"Get your API token from hardcover.app → Settings → API.": "Pridobite žeton API na hardcover.app → Nastavitve → API.",
"API Token": "Žeton API",
@@ -1446,5 +1434,36 @@
"Credentials": "Poverilnice",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Žetoni, uporabniška imena in gesla za OPDS, KOReader, Hardcover in Readwise. Ko je onemogočeno, poverilnice ostanejo le na tej napravi in se nikoli ne naložijo.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Občutljiva sinhronizirana polja so pred nalaganjem šifrirana. Nastavite geselsko frazo zdaj ali pozneje, ko bo šifriranje potrebno.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Shranjeno v tem računu. Pozvani boste, ko bo treba dešifrirati poverilnice."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Shranjeno v tem računu. Pozvani boste, ko bo treba dešifrirati poverilnice.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Napredek branja na tej napravi se razlikuje od »{{deviceName}}«.",
"This device": "Ta naprava",
"Set up KOReader Sync…": "Nastavi sinhronizacijo KOReader…",
"Set up Readwise…": "Nastavi Readwise…",
"Set up Hardcover…": "Nastavi Hardcover…",
"Red": "Rdeča",
"Yellow": "Rumena",
"Green": "Zelena",
"Blue": "Modra",
"Violet": "Vijolična",
"Edit color": "Uredi barvo",
"Add custom color": "Dodaj barvo po meri",
"Add label": "Dodaj oznako",
"Choose color": "Izberi barvo",
"Connected to Hardcover. Last synced {{time}}.": "Povezano s Hardcover. Zadnja sinhronizacija {{time}}.",
"Integrations": "Integracije",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Povezano z Readwise. Zadnja sinhronizacija {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Brskajte in prenašajte knjige iz spletnih katalogov.",
"Connected as {{user}}": "Povezano kot {{user}}",
"Not connected": "Ni povezave",
"{{count}} catalog_one": "{{count}} katalog",
"{{count}} catalog_two": "{{count}} kataloga",
"{{count}} catalog_few": "{{count}} katalogi",
"{{count}} catalog_other": "{{count}} katalogov",
"No catalogs": "Ni katalogov",
"Connect Readest to external services for sync, highlights, and catalogs.": "Povežite Readest z zunanjimi storitvami za sinhronizacijo, oznake in kataloge.",
"Reading Sync": "Sinhronizacija branja",
"Content Sources": "Viri vsebin",
"Scroll tabs": "Drsenje zavihkov"
}
@@ -199,13 +199,9 @@
"Header Bar": "Sidhuvud",
"View Options": "Visningsalternativ",
"Sync Conflict": "Synkkonflikt",
"Sync reading progress from \"{{deviceName}}\"?": "Synka läsframsteg från \"{{deviceName}}\"?",
"another device": "annan enhet",
"Local Progress": "Lokalt framsteg",
"Remote Progress": "Fjärrframsteg",
"Failed to connect": "Kunde inte ansluta",
"Disconnected": "Frånkopplad",
"KOReader Sync Settings": "KOReader-synkinställningar",
"Sync as {{userDisplayName}}": "Synka som {{userDisplayName}}",
"Sync Server Connected": "Synkserver ansluten",
"Sync Strategy": "Synkstrategi",
@@ -634,7 +630,6 @@
"Strikethrough": "Genomstrykning",
"Squiggly": "Vågig linje",
"Outline": "Kontur",
"Save Current Color": "Spara aktuell färg",
"Quick Colors": "Snabba färger",
"Highlighter": "Markeringspenna",
"Save Book Cover": "Spara bokomslag",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "Ogiltig Readwise-åtkomsttoken",
"Disconnected from Readwise": "Frånkopplad från Readwise",
"Never": "Aldrig",
"Readwise Settings": "Readwise-inställningar",
"Connected to Readwise": "Ansluten till Readwise",
"Last synced: {{time}}": "Senast synkroniserad: {{time}}",
"Sync Enabled": "Synkronisering aktiverad",
"Disconnect": "Koppla från",
"Connect your Readwise account to sync highlights.": "Anslut ditt Readwise-konto för att synkronisera markeringar.",
"Get your access token at": "Hämta din åtkomsttoken på",
"Access Token": "Åtkomsttoken",
"Paste your Readwise access token": "Klistra in din Readwise-åtkomsttoken",
"Config": "Konfiguration",
"Readwise Sync": "Readwise-synkronisering",
"Push Highlights": "Skicka markeringar",
"Highlights synced to Readwise": "Markeringar synkroniserade till Readwise",
@@ -1149,15 +1140,12 @@
"Drag to seek": "Dra för att söka",
"Punctuation Delay": "Skiljeteckenfördröjning",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Bekräfta att denna OPDS-anslutning kommer att dirigeras via Readest-servrar i webbappen innan du fortsätter.",
"Custom Headers": "Anpassade rubriker",
"Custom Headers (optional)": "Anpassade rubriker (valfritt)",
"Add one header per line using \"Header-Name: value\".": "Lägg till en rubrik per rad med formatet \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Jag förstår att denna OPDS-anslutning kommer att dirigeras via Readest-servrar i webbappen. Om jag inte litar på Readest med dessa autentiseringsuppgifter eller rubriker bör jag använda den inbyggda appen istället.",
"Unable to connect to Hardcover. Please check your network connection.": "Kan inte ansluta till Hardcover. Kontrollera din nätverksanslutning.",
"Invalid Hardcover API token": "Ogiltig Hardcover API-token",
"Disconnected from Hardcover": "Frånkopplad från Hardcover",
"Hardcover Settings": "Hardcover-inställningar",
"Connected to Hardcover": "Ansluten till Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Anslut ditt Hardcover-konto för att synkronisera läsframsteg och anteckningar.",
"Get your API token from hardcover.app → Settings → API.": "Hämta din API-token från hardcover.app → Inställningar → API.",
"API Token": "API-token",
@@ -1398,5 +1386,34 @@
"Credentials": "Inloggningsuppgifter",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Tokens, användarnamn och lösenord för OPDS, KOReader, Hardcover och Readwise. När inaktiverat förblir inloggningsuppgifterna endast på denna enhet och laddas aldrig upp.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Känsliga synkroniserade fält krypteras före uppladdning. Ange en lösenfras nu eller senare när kryptering behövs.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Sparad på det här kontot. Du blir tillfrågad när inloggningsuppgifter ska dekrypteras."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Sparad på det här kontot. Du blir tillfrågad när inloggningsuppgifter ska dekrypteras.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Läsförloppet på den här enheten skiljer sig från \"{{deviceName}}\".",
"This device": "Den här enheten",
"Set up KOReader Sync…": "Konfigurera KOReader-synkronisering…",
"Set up Readwise…": "Konfigurera Readwise…",
"Set up Hardcover…": "Konfigurera Hardcover…",
"Red": "Röd",
"Yellow": "Gul",
"Green": "Grön",
"Blue": "Blå",
"Violet": "Violett",
"Edit color": "Redigera färg",
"Add custom color": "Lägg till anpassad färg",
"Add label": "Lägg till etikett",
"Choose color": "Välj färg",
"Connected to Hardcover. Last synced {{time}}.": "Ansluten till Hardcover. Senast synkroniserad {{time}}.",
"Integrations": "Integrationer",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Ansluten till Readwise. Senast synkroniserad {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Bläddra i och ladda ner böcker från onlinekataloger.",
"Connected as {{user}}": "Ansluten som {{user}}",
"Not connected": "Inte ansluten",
"{{count}} catalog_one": "{{count}} katalog",
"{{count}} catalog_other": "{{count}} kataloger",
"No catalogs": "Inga kataloger",
"Connect Readest to external services for sync, highlights, and catalogs.": "Anslut Readest till externa tjänster för synkronisering, markeringar och kataloger.",
"Reading Sync": "Lässynkronisering",
"Content Sources": "Innehållskällor",
"Scroll tabs": "Bläddra mellan flikar"
}
@@ -155,13 +155,9 @@
"Small": "சிறிய",
"Large": "பெரிய",
"Sync Conflict": "ஒத்திசைவு முரண்பாடு",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" இல் இருந்து வாசிப்பு முன்னேற்றத்தை ஒத்திசைக்கவா?",
"another device": "மற்றொரு சாதனம்",
"Local Progress": "உள்ளூர் முன்னேற்றம்",
"Remote Progress": "தொலை முன்னேற்றம்",
"Failed to connect": "இணைக்க முடியவில்லை",
"Disconnected": "துண்டிக்கப்பட்டது",
"KOReader Sync Settings": "KOReader ஒத்திசைவு அமைப்புகள்",
"Sync as {{userDisplayName}}": "{{userDisplayName}} ஆக ஒத்திசைக்கவும்",
"Sync Server Connected": "ஒத்திசைவு சர்வர் இணைக்கப்பட்டது",
"Sync Strategy": "ஒத்திசைவு உத்தி",
@@ -634,7 +630,6 @@
"Strikethrough": "குறிக்கோடு",
"Squiggly": "சுருள் கோடு",
"Outline": "வட்டம்",
"Save Current Color": "தற்போதைய நிறத்தை சேமிக்கவும்",
"Quick Colors": "விரைவு நிறங்கள்",
"Highlighter": "ஹைலைட்டர்",
"Save Book Cover": "புத்தகக் கோப்பைச் சேமிக்கவும்",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "தவறான Readwise அணுகல் டோக்கன்",
"Disconnected from Readwise": "Readwise இலிருந்து துண்டிக்கப்பட்டது",
"Never": "ஒருபோதும் இல்லை",
"Readwise Settings": "Readwise அமைப்புகள்",
"Connected to Readwise": "Readwise உடன் இணைக்கப்பட்டது",
"Last synced: {{time}}": "கடைசியாக ஒத்திசைக்கப்பட்டது: {{time}}",
"Sync Enabled": "ஒத்திசைவு இயக்கப்பட்டது",
"Disconnect": "துண்டி",
"Connect your Readwise account to sync highlights.": "சிறப்பம்சங்களை ஒத்திசைக்க உங்கள் Readwise கணக்கை இணைக்கவும்.",
"Get your access token at": "உங்கள் அணுகல் டோக்கனை இங்கே பெறவும்",
"Access Token": "அணுகல் டோக்கன்",
"Paste your Readwise access token": "உங்கள் Readwise அணுகல் டோக்கனை ஒட்டவும்",
"Config": "கட்டமைப்பு",
"Readwise Sync": "Readwise ஒத்திசைவு",
"Push Highlights": "சிறப்பம்சங்களை அனுப்பு",
"Highlights synced to Readwise": "சிறப்பம்சங்கள் Readwise உடன் ஒத்திசைக்கப்பட்டன",
@@ -1149,15 +1140,12 @@
"Drag to seek": "தேட இழுக்கவும்",
"Punctuation Delay": "நிறுத்தக்குறி தாமதம்",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "தொடர்வதற்கு முன், இந்த OPDS இணைப்பு வலை பயன்பாட்டில் Readest சேவையகங்கள் வழியாக ப்ராக்ஸி செய்யப்படும் என்பதை உறுதிப்படுத்தவும்.",
"Custom Headers": "தனிப்பயன் தலைப்புகள்",
"Custom Headers (optional)": "தனிப்பயன் தலைப்புகள் (விரும்பினால்)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" வடிவமைப்பைப் பயன்படுத்தி ஒவ்வொரு வரியிலும் ஒரு தலைப்பைச் சேர்க்கவும்.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "இந்த OPDS இணைப்பு வலை பயன்பாட்டில் Readest சேவையகங்கள் வழியாக ப்ராக்ஸி செய்யப்படும் என்பதை நான் புரிந்துகொள்கிறேன். இந்த சான்றுகள் அல்லது தலைப்புகளுடன் Readest-ஐ நம்பவில்லை என்றால், நான் நேட்டிவ் பயன்பாட்டைப் பயன்படுத்த வேண்டும்.",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover உடன் இணைக்க முடியவில்லை. உங்கள் நெட்வொர்க் இணைப்பைச் சரிபார்க்கவும்.",
"Invalid Hardcover API token": "தவறான Hardcover API டோக்கன்",
"Disconnected from Hardcover": "Hardcover இலிருந்து துண்டிக்கப்பட்டது",
"Hardcover Settings": "Hardcover அமைப்புகள்",
"Connected to Hardcover": "Hardcover உடன் இணைக்கப்பட்டது",
"Connect your Hardcover account to sync reading progress and notes.": "வாசிப்பு முன்னேற்றம் மற்றும் குறிப்புகளை ஒத்திசைக்க உங்கள் Hardcover கணக்கை இணைக்கவும்.",
"Get your API token from hardcover.app → Settings → API.": "hardcover.app → அமைப்புகள் → API இலிருந்து உங்கள் API டோக்கனைப் பெறவும்.",
"API Token": "API டோக்கன்",
@@ -1398,5 +1386,34 @@
"Credentials": "அங்கீகாரத் தரவுகள்",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS, KOReader, Hardcover மற்றும் Readwise-க்கான டோக்கன்கள், பயனர் பெயர்கள் மற்றும் கடவுச்சொற்கள். முடக்கப்பட்டிருக்கும் போது, அங்கீகாரத் தரவுகள் இந்த சாதனத்தில் மட்டுமே இருக்கும், ஒருபோதும் பதிவேற்றப்படாது.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "முக்கியமான ஒத்திசைக்கப்பட்ட புலங்கள் பதிவேற்றத்திற்கு முன் குறியாக்கம் செய்யப்படுகின்றன. குறியாக்கம் தேவைப்படும் போது இப்போது அல்லது பின்னர் கடவுச்சொற்றொடரை அமைக்கவும்.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "இந்த கணக்கில் சேமிக்கப்பட்டது. அங்கீகாரத் தரவுகளை மறைகுறியாக்கும்போது உங்களிடம் கேட்கப்படும்."
"Saved to this account. You will be prompted for it when decrypting credentials.": "இந்த கணக்கில் சேமிக்கப்பட்டது. அங்கீகாரத் தரவுகளை மறைகுறியாக்கும்போது உங்களிடம் கேட்கப்படும்.",
"Reading progress on this device differs from \"{{deviceName}}\".": "இந்தச் சாதனத்தில் படிப்பு முன்னேற்றம் \"{{deviceName}}\" என்பதிலிருந்து வேறுபடுகிறது.",
"This device": "இந்தச் சாதனம்",
"Set up KOReader Sync…": "KOReader ஒத்திசைவை அமைக்கவும்…",
"Set up Readwise…": "Readwise ஐ அமைக்கவும்…",
"Set up Hardcover…": "Hardcover ஐ அமைக்கவும்…",
"Red": "சிவப்பு",
"Yellow": "மஞ்சள்",
"Green": "பச்சை",
"Blue": "நீலம்",
"Violet": "ஊதா",
"Edit color": "நிறத்தைத் திருத்து",
"Add custom color": "தனிப்பயன் நிறத்தைச் சேர்க்கவும்",
"Add label": "லேபிளைச் சேர்க்கவும்",
"Choose color": "நிறத்தைத் தேர்ந்தெடுக்கவும்",
"Connected to Hardcover. Last synced {{time}}.": "Hardcover உடன் இணைக்கப்பட்டது. கடைசி ஒத்திசைவு {{time}}.",
"Integrations": "ஒருங்கிணைப்புகள்",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwise உடன் இணைக்கப்பட்டது. கடைசி ஒத்திசைவு {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "ஆன்லைன் பட்டியல்களில் இருந்து புத்தகங்களை உலாவி பதிவிறக்கவும்.",
"Connected as {{user}}": "{{user}} ஆக இணைக்கப்பட்டது",
"Not connected": "இணைக்கப்படவில்லை",
"{{count}} catalog_one": "{{count}} பட்டியல்",
"{{count}} catalog_other": "{{count}} பட்டியல்கள்",
"No catalogs": "பட்டியல்கள் இல்லை",
"Connect Readest to external services for sync, highlights, and catalogs.": "ஒத்திசைவு, ஹைலைட்கள் மற்றும் பட்டியல்களுக்காக Readest ஐ வெளிப்புற சேவைகளுடன் இணைக்கவும்.",
"Reading Sync": "படிப்பு ஒத்திசைவு",
"Content Sources": "உள்ளடக்க மூலங்கள்",
"Scroll tabs": "தாவல்களை உருட்டவும்"
}
@@ -456,7 +456,6 @@
"Remove from Cloud Only": "ลบจากคลาวด์เท่านั้น",
"Remove from Device Only": "ลบจากอุปกรณ์เท่านั้น",
"Disconnected": "ตัดการเชื่อมต่อ",
"KOReader Sync Settings": "การตั้งค่าการซิงค์ KOReader",
"Sync Strategy": "กลยุทธ์การซิงค์",
"Ask on conflict": "ถามเมื่อเกิดความขัดแย้ง",
"Always use latest": "ใช้เวอร์ชันล่าสุดเสมอ",
@@ -474,10 +473,7 @@
"Connect": "เชื่อมต่อ",
"KOReader Sync": "KOReader Sync",
"Sync Conflict": "ความขัดแย้งในการซิงค์",
"Sync reading progress from \"{{deviceName}}\"?": "ซิงค์ความก้าวหน้าในการอ่านจาก \"{{deviceName}}\"?",
"another device": "อุปกรณ์อื่น",
"Local Progress": "ความก้าวหน้าในเครื่อง",
"Remote Progress": "ความก้าวหน้าในคลาวด์",
"Page {{page}} of {{total}} ({{percentage}}%)": "หน้า {{page}} จาก {{total}} ({{percentage}}%)",
"Current position": "ตำแหน่งปัจจุบัน",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ประมาณหน้า {{page}} จาก {{total}} ({{percentage}}%)",
@@ -630,7 +626,6 @@
"Strikethrough": "ขีดฆ่า",
"Squiggly": "เส้นหยัก",
"Outline": "เส้นขอบ",
"Save Current Color": "บันทึกสีปัจจุบัน",
"Quick Colors": "สีด่วน",
"Highlighter": "ปากกาเน้นข้อความ",
"Save Book Cover": "บันทึกปกหนังสือ",
@@ -1022,16 +1017,12 @@
"Invalid Readwise access token": "โทเคนการเข้าถึง Readwise ไม่ถูกต้อง",
"Disconnected from Readwise": "ยกเลิกการเชื่อมต่อกับ Readwise แล้ว",
"Never": "ไม่เคย",
"Readwise Settings": "การตั้งค่า Readwise",
"Connected to Readwise": "เชื่อมต่อกับ Readwise แล้ว",
"Last synced: {{time}}": "ซิงค์ล่าสุดเมื่อ: {{time}}",
"Sync Enabled": "เปิดใช้งานการซิงค์",
"Disconnect": "ยกเลิกการเชื่อมต่อ",
"Connect your Readwise account to sync highlights.": "เชื่อมต่อบัญชี Readwise ของคุณเพื่อซิงค์ไฮไลต์",
"Get your access token at": "รับโทเคนการเข้าถึงของคุณได้ที่",
"Access Token": "โทเคนการเข้าถึง",
"Paste your Readwise access token": "วางโทเคนการเข้าถึง Readwise ของคุณ",
"Config": "การกำหนดค่า",
"Readwise Sync": "การซิงค์ Readwise",
"Push Highlights": "พุชไฮไลต์",
"Highlights synced to Readwise": "ซิงค์ไฮไลต์ไปยัง Readwise แล้ว",
@@ -1136,15 +1127,12 @@
"Drag to seek": "ลากเพื่อค้นหา",
"Punctuation Delay": "หน่วงเวลาเครื่องหมายวรรคตอน",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "กรุณายืนยันว่าการเชื่อมต่อ OPDS นี้จะถูกส่งผ่านเซิร์ฟเวอร์ Readest บนเว็บแอปก่อนดำเนินการต่อ",
"Custom Headers": "ส่วนหัวที่กำหนดเอง",
"Custom Headers (optional)": "ส่วนหัวที่กำหนดเอง (ไม่บังคับ)",
"Add one header per line using \"Header-Name: value\".": "เพิ่มส่วนหัวหนึ่งรายการต่อบรรทัดโดยใช้ \"Header-Name: value\"",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "ฉันเข้าใจว่าการเชื่อมต่อ OPDS นี้จะถูกส่งผ่านเซิร์ฟเวอร์ Readest บนเว็บแอป หากฉันไม่ไว้วางใจ Readest กับข้อมูลรับรองหรือส่วนหัวเหล่านี้ ฉันควรใช้แอปเนทีฟแทน",
"Unable to connect to Hardcover. Please check your network connection.": "ไม่สามารถเชื่อมต่อกับ Hardcover ได้ กรุณาตรวจสอบการเชื่อมต่อเครือข่ายของคุณ",
"Invalid Hardcover API token": "โทเค็น API ของ Hardcover ไม่ถูกต้อง",
"Disconnected from Hardcover": "ตัดการเชื่อมต่อจาก Hardcover แล้ว",
"Hardcover Settings": "การตั้งค่า Hardcover",
"Connected to Hardcover": "เชื่อมต่อกับ Hardcover แล้ว",
"Connect your Hardcover account to sync reading progress and notes.": "เชื่อมต่อบัญชี Hardcover ของคุณเพื่อซิงค์ความคืบหน้าการอ่านและบันทึก",
"Get your API token from hardcover.app → Settings → API.": "รับโทเค็น API ของคุณจาก hardcover.app → การตั้งค่า → API",
"API Token": "โทเค็น API",
@@ -1374,5 +1362,33 @@
"Credentials": "ข้อมูลรับรอง",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "โทเคน ชื่อผู้ใช้ และรหัสผ่านสำหรับ OPDS, KOReader, Hardcover และ Readwise เมื่อปิดใช้งาน ข้อมูลรับรองจะยังคงอยู่ในอุปกรณ์นี้เท่านั้นและจะไม่ถูกอัปโหลด",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "ฟิลด์ที่ซิงค์ที่มีความละเอียดอ่อนจะถูกเข้ารหัสก่อนอัปโหลด ตั้งวลีรหัสผ่านตอนนี้หรือภายหลังเมื่อจำเป็นต้องเข้ารหัส",
"Saved to this account. You will be prompted for it when decrypting credentials.": "บันทึกในบัญชีนี้แล้ว คุณจะได้รับแจ้งเมื่อจำเป็นต้องถอดรหัสข้อมูลรับรอง"
"Saved to this account. You will be prompted for it when decrypting credentials.": "บันทึกในบัญชีนี้แล้ว คุณจะได้รับแจ้งเมื่อจำเป็นต้องถอดรหัสข้อมูลรับรอง",
"Reading progress on this device differs from \"{{deviceName}}\".": "ความคืบหน้าการอ่านบนอุปกรณ์นี้แตกต่างจาก \"{{deviceName}}\"",
"This device": "อุปกรณ์นี้",
"Set up KOReader Sync…": "ตั้งค่าการซิงค์ KOReader…",
"Set up Readwise…": "ตั้งค่า Readwise…",
"Set up Hardcover…": "ตั้งค่า Hardcover…",
"Red": "แดง",
"Yellow": "เหลือง",
"Green": "เขียว",
"Blue": "น้ำเงิน",
"Violet": "ม่วง",
"Edit color": "แก้ไขสี",
"Add custom color": "เพิ่มสีที่กำหนดเอง",
"Add label": "เพิ่มป้ายกำกับ",
"Choose color": "เลือกสี",
"Connected to Hardcover. Last synced {{time}}.": "เชื่อมต่อกับ Hardcover แล้ว ซิงค์ล่าสุด {{time}}",
"Integrations": "การเชื่อมต่อ",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "เชื่อมต่อกับ Readwise แล้ว ซิงค์ล่าสุด {{time}}",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "เรียกดูและดาวน์โหลดหนังสือจากแคตตาล็อกออนไลน์",
"Connected as {{user}}": "เชื่อมต่อในชื่อ {{user}}",
"Not connected": "ไม่ได้เชื่อมต่อ",
"{{count}} catalog_other": "แคตตาล็อก {{count}} รายการ",
"No catalogs": "ไม่มีแคตตาล็อก",
"Connect Readest to external services for sync, highlights, and catalogs.": "เชื่อมต่อ Readest กับบริการภายนอกเพื่อการซิงค์ ไฮไลต์ และแคตตาล็อก",
"Reading Sync": "การซิงค์การอ่าน",
"Content Sources": "แหล่งเนื้อหา",
"Scroll tabs": "เลื่อนแท็บ"
}
@@ -460,7 +460,6 @@
"Remove from Cloud Only": "Sadece Buluttan Kaldır",
"Remove from Device Only": "Sadece Cihazdan Kaldır",
"Disconnected": "Bağlantı Kesildi",
"KOReader Sync Settings": "KOReader Senkronizasyon Ayarları",
"Sync Strategy": "Senkronizasyon Stratejisi",
"Ask on conflict": "Çatışmada Sor",
"Always use latest": "Her Zaman En Sonunu Kullan",
@@ -478,10 +477,7 @@
"Connect": "Bağlan",
"KOReader Sync": "KOReader Senkronizasyon",
"Sync Conflict": "Senkronizasyon Çatışması",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" cihazından okuma ilerlemesini senkronize etmek istiyor musunuz?",
"another device": "başka bir cihaz",
"Local Progress": "Yerel İlerleme",
"Remote Progress": "Uzak İlerleme",
"Page {{page}} of {{total}} ({{percentage}}%)": "Sayfa {{page}} / {{total}} ({{percentage}}%)",
"Current position": "Mevcut konum",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Yaklaşık sayfa {{page}} / {{total}} ({{percentage}}%)",
@@ -634,7 +630,6 @@
"Strikethrough": "Üstü Çizili",
"Squiggly": "Dalgalı Çizgi",
"Outline": "Kontur",
"Save Current Color": "Mevcut Rengi Kaydet",
"Quick Colors": "Hızlı Renkler",
"Highlighter": "Vurgulayıcı",
"Save Book Cover": "Kitap Kapağını Kaydet",
@@ -1034,16 +1029,12 @@
"Invalid Readwise access token": "Geçersiz Readwise erişim kodu",
"Disconnected from Readwise": "Readwise bağlantısı kesildi",
"Never": "Asla",
"Readwise Settings": "Readwise Ayarları",
"Connected to Readwise": "Readwise'a Bağlandı",
"Last synced: {{time}}": "Son senkronizasyon: {{time}}",
"Sync Enabled": "Senkronizasyon Etkin",
"Disconnect": "Bağlantıyı Kes",
"Connect your Readwise account to sync highlights.": "Vurguları senkronize etmek için Readwise hesabınızı bağlayın.",
"Get your access token at": "Erişim kodunuzu şuradan alın:",
"Access Token": "Erişim Kodu",
"Paste your Readwise access token": "Readwise erişim kodunuzu yapıştırın",
"Config": "Yapılandırma",
"Readwise Sync": "Readwise Senkronizasyonu",
"Push Highlights": "Vurguları Gönder",
"Highlights synced to Readwise": "Vurgular Readwise ile senkronize edildi",
@@ -1149,15 +1140,12 @@
"Drag to seek": "Aramak için sürükleyin",
"Punctuation Delay": "Noktalama gecikmesi",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Devam etmeden önce bu OPDS bağlantısının web uygulamasında Readest sunucuları üzerinden yönlendirileceğini onaylayın.",
"Custom Headers": "Özel Başlıklar",
"Custom Headers (optional)": "Özel Başlıklar (isteğe bağlı)",
"Add one header per line using \"Header-Name: value\".": "\"Header-Name: value\" biçimini kullanarak her satıra bir başlık ekleyin.",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Bu OPDS bağlantısının web uygulamasında Readest sunucuları üzerinden yönlendirileceğini anlıyorum. Bu kimlik bilgileri veya başlıklar konusunda Readest'e güvenmiyorsam, bunun yerine yerel uygulamayı kullanmalıyım.",
"Unable to connect to Hardcover. Please check your network connection.": "Hardcover'a bağlanılamıyor. Lütfen ağ bağlantınızı kontrol edin.",
"Invalid Hardcover API token": "Geçersiz Hardcover API belirteci",
"Disconnected from Hardcover": "Hardcover bağlantısı kesildi",
"Hardcover Settings": "Hardcover Ayarları",
"Connected to Hardcover": "Hardcover'a bağlandı",
"Connect your Hardcover account to sync reading progress and notes.": "Okuma ilerlemenizi ve notlarınızı senkronize etmek için Hardcover hesabınızı bağlayın.",
"Get your API token from hardcover.app → Settings → API.": "API belirtecinizi hardcover.app → Ayarlar → API'den alın.",
"API Token": "API Belirteci",
@@ -1398,5 +1386,34 @@
"Credentials": "Kimlik Bilgileri",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS, KOReader, Hardcover ve Readwise için belirteçler, kullanıcı adları ve parolalar. Devre dışı bırakıldığında kimlik bilgileri yalnızca bu cihazda kalır ve hiçbir zaman karşıya yüklenmez.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Hassas senkronize alanlar yüklemeden önce şifrelenir. Şifreleme gerektiğinde şimdi ya da daha sonra bir parola tümcesi belirleyin.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Bu hesaba kaydedildi. Kimlik bilgileri şifresi çözülürken sizden istenecektir."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Bu hesaba kaydedildi. Kimlik bilgileri şifresi çözülürken sizden istenecektir.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Bu cihazdaki okuma ilerlemesi \"{{deviceName}}\" cihazından farklı.",
"This device": "Bu cihaz",
"Set up KOReader Sync…": "KOReader Sync'i kur…",
"Set up Readwise…": "Readwise'i kur…",
"Set up Hardcover…": "Hardcover'ı kur…",
"Red": "Kırmızı",
"Yellow": "Sarı",
"Green": "Yeşil",
"Blue": "Mavi",
"Violet": "Mor",
"Edit color": "Rengi düzenle",
"Add custom color": "Özel renk ekle",
"Add label": "Etiket ekle",
"Choose color": "Renk seç",
"Connected to Hardcover. Last synced {{time}}.": "Hardcover'a bağlı. Son senkronizasyon {{time}}.",
"Integrations": "Entegrasyonlar",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwise'a bağlı. Son senkronizasyon {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Çevrimiçi kataloglardan kitaplara göz atın ve indirin.",
"Connected as {{user}}": "{{user}} olarak bağlandı",
"Not connected": "Bağlı değil",
"{{count}} catalog_one": "{{count}} katalog",
"{{count}} catalog_other": "{{count}} katalog",
"No catalogs": "Katalog yok",
"Connect Readest to external services for sync, highlights, and catalogs.": "Senkronizasyon, vurgular ve kataloglar için Readest'i harici hizmetlere bağlayın.",
"Reading Sync": "Okuma senkronizasyonu",
"Content Sources": "İçerik kaynakları",
"Scroll tabs": "Sekmeleri kaydır"
}
@@ -468,7 +468,6 @@
"Remove from Cloud Only": "Видалити тільки із хмари",
"Remove from Device Only": "Видалити тільки із пристрою",
"Disconnected": "Від'єднано",
"KOReader Sync Settings": "Налаштування синхронізації KOReader",
"Sync Strategy": "Стратегія синхронізації",
"Ask on conflict": "Запитувати при конфлікті",
"Always use latest": "Завжди використовувати найновішу",
@@ -486,10 +485,7 @@
"Connect": "Під'єднатися",
"KOReader Sync": "Синхронізація KOReader",
"Sync Conflict": "Конфлікт синхронізації",
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання із \"{{deviceName}}\"?",
"another device": "інший пристрій",
"Local Progress": "Локальний проґрес",
"Remote Progress": "Проґрес у хмарі",
"Page {{page}} of {{total}} ({{percentage}}%)": "Сторінка {{page}} з {{total}} ({{percentage}}%)",
"Current position": "Поточна позиція",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} із {{total}} ({{percentage}}%)",
@@ -642,7 +638,6 @@
"Strikethrough": "Закреслення",
"Squiggly": "Хвиляста лінія",
"Outline": "Контур",
"Save Current Color": "Зберегти поточний колір",
"Quick Colors": "Швидкі кольори",
"Highlighter": "Маркер",
"Save Book Cover": "Зберегти обкладинку книги",
@@ -1058,16 +1053,12 @@
"Invalid Readwise access token": "Недійсний токен доступу Readwise",
"Disconnected from Readwise": "Відключено від Readwise",
"Never": "Ніколи",
"Readwise Settings": "Налаштування Readwise",
"Connected to Readwise": "Підключено до Readwise",
"Last synced: {{time}}": "Остання синхронізація: {{time}}",
"Sync Enabled": "Синхронізацію увімкнено",
"Disconnect": "Відключити",
"Connect your Readwise account to sync highlights.": "Підключіть свій обліковий запис Readwise, щоб синхронізувати виділення.",
"Get your access token at": "Отримайте токен доступу за адресою",
"Access Token": "Токен доступу",
"Paste your Readwise access token": "Вставте ваш токен доступу Readwise",
"Config": "Конфігурація",
"Readwise Sync": "Синхронізація Readwise",
"Push Highlights": "Надіслати виділення",
"Highlights synced to Readwise": "Виділення синхронізовано з Readwise",
@@ -1175,15 +1166,12 @@
"Drag to seek": "Перетягніть для пошуку",
"Punctuation Delay": "Затримка на розділових знаках",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Підтвердіть, що це з'єднання OPDS буде проксовано через сервери Readest у веб-додатку, перш ніж продовжити.",
"Custom Headers": "Користувацькі заголовки",
"Custom Headers (optional)": "Користувацькі заголовки (необов'язково)",
"Add one header per line using \"Header-Name: value\".": "Додайте один заголовок на рядок у форматі \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Я розумію, що це з'єднання OPDS буде проксовано через сервери Readest у веб-додатку. Якщо я не довіряю Readest свої облікові дані або заголовки, мені слід використовувати рідний додаток.",
"Unable to connect to Hardcover. Please check your network connection.": "Не вдалося підключитися до Hardcover. Перевірте мережеве з'єднання.",
"Invalid Hardcover API token": "Недійсний API-токен Hardcover",
"Disconnected from Hardcover": "Від'єднано від Hardcover",
"Hardcover Settings": "Налаштування Hardcover",
"Connected to Hardcover": "Підключено до Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Підключіть свій обліковий запис Hardcover для синхронізації прогресу читання та нотаток.",
"Get your API token from hardcover.app → Settings → API.": "Отримайте API-токен на hardcover.app → Налаштування → API.",
"API Token": "API-токен",
@@ -1446,5 +1434,36 @@
"Credentials": "Облікові дані",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Токени, імена користувачів і паролі для OPDS, KOReader, Hardcover та Readwise. Якщо вимкнено, облікові дані залишаються лише на цьому пристрої і ніколи не вивантажуються.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Конфіденційні синхронізовані поля шифруються перед вивантаженням. Установіть парольну фразу зараз або пізніше, коли знадобиться шифрування.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Збережено в цьому обліковому записі. Вас запитають про неї під час розшифровування облікових даних."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Збережено в цьому обліковому записі. Вас запитають про неї під час розшифровування облікових даних.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Прогрес читання на цьому пристрої відрізняється від «{{deviceName}}».",
"This device": "Цей пристрій",
"Set up KOReader Sync…": "Налаштувати синхронізацію KOReader…",
"Set up Readwise…": "Налаштувати Readwise…",
"Set up Hardcover…": "Налаштувати Hardcover…",
"Red": "Червоний",
"Yellow": "Жовтий",
"Green": "Зелений",
"Blue": "Синій",
"Violet": "Фіолетовий",
"Edit color": "Редагувати колір",
"Add custom color": "Додати власний колір",
"Add label": "Додати мітку",
"Choose color": "Вибрати колір",
"Connected to Hardcover. Last synced {{time}}.": "Підключено до Hardcover. Остання синхронізація {{time}}.",
"Integrations": "Інтеграції",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Підключено до Readwise. Остання синхронізація {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Переглядайте та завантажуйте книги з онлайн-каталогів.",
"Connected as {{user}}": "Підключено як {{user}}",
"Not connected": "Не підключено",
"{{count}} catalog_one": "{{count}} каталог",
"{{count}} catalog_few": "{{count}} каталоги",
"{{count}} catalog_many": "{{count}} каталогів",
"{{count}} catalog_other": "{{count}} каталогу",
"No catalogs": "Немає каталогів",
"Connect Readest to external services for sync, highlights, and catalogs.": "Підключіть Readest до зовнішніх сервісів для синхронізації, виділень і каталогів.",
"Reading Sync": "Синхронізація читання",
"Content Sources": "Джерела вмісту",
"Scroll tabs": "Прокрутити вкладки"
}
@@ -276,7 +276,6 @@
"Edit": "Tahrirlash",
"Remove": "Olib tashlash",
"Username": "Foydalanuvchi nomi",
"Custom Headers": "Maxsus sarlavhalar",
"Auto-download": "Avtomatik yuklab olish",
"Last synced {{when}}": "Oxirgi sinxronlash {{when}}",
"{{count}} failed_one": "{{count}} ta muvaffaqiyatsiz",
@@ -498,9 +497,6 @@
"Invalid Hardcover API token": "Notoʻgʻri Hardcover API tokeni",
"Disconnected from Hardcover": "Hardcover-dan uzildi",
"Never": "Hech qachon",
"Hardcover Settings": "Hardcover sozlamalari",
"Connected to Hardcover": "Hardcover-ga ulandi",
"Last synced: {{time}}": "Oxirgi sinxronlash: {{time}}",
"Sync Enabled": "Sinxronlash yoqilgan",
"Disconnect": "Uzish",
"Connect your Hardcover account to sync reading progress and notes.": "Oʻqish jarayoni va eslatmalarni sinxronlash uchun Hardcover hisobingizni ulang.",
@@ -520,13 +516,9 @@
"Zoomed": "Kattalashtirilgan",
"Zoom level": "Kattalashtirish darajasi",
"Sync Conflict": "Sinxronlash ziddiyati",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" qurilmasidan oʻqish jarayonini sinxronlaysizmi?",
"another device": "boshqa qurilma",
"Local Progress": "Mahalliy jarayon",
"Remote Progress": "Masofaviy jarayon",
"Failed to connect": "Ulanib boʻlmadi",
"Disconnected": "Uzildi",
"KOReader Sync Settings": "KOReader sinxronlash sozlamalari",
"Sync as {{userDisplayName}}": "{{userDisplayName}} sifatida sinxronlash",
"Sync Server Connected": "Sinxronlash serveriga ulandi",
"Sync Strategy": "Sinxronlash strategiyasi",
@@ -583,8 +575,6 @@
"Unable to connect to Readwise. Please check your network connection.": "Readwise-ga ulanib boʻlmadi. Tarmoq ulanishingizni tekshiring.",
"Invalid Readwise access token": "Notoʻgʻri Readwise kirish tokeni",
"Disconnected from Readwise": "Readwise-dan uzildi",
"Readwise Settings": "Readwise sozlamalari",
"Connected to Readwise": "Readwise-ga ulandi",
"Connect your Readwise account to sync highlights.": "Belgilashlarni sinxronlash uchun Readwise hisobingizni ulang.",
"Get your access token at": "Kirish tokeningizni shu yerdan oling:",
"Access Token": "Kirish tokeni",
@@ -642,7 +632,6 @@
"Exit Parallel Read": "Parallel oʻqishdan chiqish",
"Enter Parallel Read": "Parallel oʻqishga kirish",
"KOReader Sync": "KOReader sinxronlash",
"Config": "Konfiguratsiya",
"Push Progress": "Jarayonni yuborish",
"Pull Progress": "Jarayonni olish",
"Readwise Sync": "Readwise sinxronlash",
@@ -1027,7 +1016,6 @@
"Strikethrough": "Ustidan chizish",
"Squiggly": "Egri chiziq",
"Outline": "Konturli",
"Save Current Color": "Joriy rangni saqlash",
"Quick Colors": "Tezkor ranglar",
"Override Book Color": "Kitob rangini bekor qilish",
"None": "Yoʻq",
@@ -1398,5 +1386,34 @@
"Credentials": "Hisob ma'lumotlari",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS, KOReader, Hardcover va Readwise uchun tokenlar, foydalanuvchi nomlari va parollar. O'chirilganda, hisob ma'lumotlari faqat shu qurilmada qoladi va hech qachon yuklanmaydi.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Maxfiy sinxronlashtirilgan maydonlar yuklashdan oldin shifrlanadi. Hozir yoki keyinroq, shifrlash kerak bo'lganda parol iborasini o'rnating.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Ushbu hisobga saqlangan. Hisob ma'lumotlarini deshifrlashda sizdan so'raladi."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Ushbu hisobga saqlangan. Hisob ma'lumotlarini deshifrlashda sizdan so'raladi.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Bu qurilmadagi o'qish jarayoni \"{{deviceName}}\" qurilmasidan farq qiladi.",
"This device": "Ushbu qurilma",
"Set up KOReader Sync…": "KOReader Sync'ni sozlash…",
"Set up Readwise…": "Readwise'ni sozlash…",
"Set up Hardcover…": "Hardcover'ni sozlash…",
"Red": "Qizil",
"Yellow": "Sariq",
"Green": "Yashil",
"Blue": "Ko'k",
"Violet": "Binafsha",
"Edit color": "Rangni tahrirlash",
"Add custom color": "Maxsus rang qo'shish",
"Add label": "Yorliq qo'shish",
"Choose color": "Rangni tanlash",
"Connected to Hardcover. Last synced {{time}}.": "Hardcover'ga ulangan. Oxirgi sinxronlash {{time}}.",
"Integrations": "Integratsiyalar",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Readwise'ga ulangan. Oxirgi sinxronlash {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Onlayn kataloglardan kitoblarni ko'rib chiqing va yuklab oling.",
"Connected as {{user}}": "{{user}} sifatida ulangan",
"Not connected": "Ulanmagan",
"{{count}} catalog_one": "{{count}} katalog",
"{{count}} catalog_other": "{{count}} katalog",
"No catalogs": "Kataloglar yo'q",
"Connect Readest to external services for sync, highlights, and catalogs.": "Sinxronizatsiya, ajratishlar va kataloglar uchun Readest'ni tashqi xizmatlarga ulang.",
"Reading Sync": "O'qish sinxronlashi",
"Content Sources": "Kontent manbalari",
"Scroll tabs": "Yorliqlarni aylantirish"
}
@@ -456,7 +456,6 @@
"Remove from Cloud Only": "Xóa chỉ khỏi Đám mây",
"Remove from Device Only": "Xóa chỉ khỏi Thiết bị",
"Disconnected": "Mất kết nối",
"KOReader Sync Settings": "Cài đặt đồng bộ KOReader",
"Sync Strategy": "Chiến lược đồng bộ",
"Ask on conflict": "Hỏi khi có xung đột",
"Always use latest": "Luôn sử dụng phiên bản mới nhất",
@@ -474,10 +473,7 @@
"Connect": "Kết nối",
"KOReader Sync": "Đồng bộ KOReader",
"Sync Conflict": "Xung đột đồng bộ",
"Sync reading progress from \"{{deviceName}}\"?": "Đồng bộ tiến độ đọc từ \"{{deviceName}}\"?",
"another device": "thiết bị khác",
"Local Progress": "Tiến độ cục bộ",
"Remote Progress": "Tiến độ từ xa",
"Page {{page}} of {{total}} ({{percentage}}%)": "Trang {{page}} của {{total}} ({{percentage}}%)",
"Current position": "Vị trí hiện tại",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Khoảng trang {{page}} của {{total}} ({{percentage}}%)",
@@ -630,7 +626,6 @@
"Strikethrough": "Gạch ngang",
"Squiggly": "Gạch ngoằn ngoèo",
"Outline": "Đường viền",
"Save Current Color": "Lưu màu hiện tại",
"Quick Colors": "Màu nhanh",
"Highlighter": "Bút đánh dấu",
"Save Book Cover": "Lưu bìa sách",
@@ -1022,16 +1017,12 @@
"Invalid Readwise access token": "Mã xác thực Readwise không hợp lệ",
"Disconnected from Readwise": "Đã ngắt kết nối với Readwise",
"Never": "Không bao giờ",
"Readwise Settings": "Cài đặt Readwise",
"Connected to Readwise": "Đã kết nối với Readwise",
"Last synced: {{time}}": "Lần đồng bộ cuối: {{time}}",
"Sync Enabled": "Đã bật đồng bộ",
"Disconnect": "Ngắt kết nối",
"Connect your Readwise account to sync highlights.": "Kết nối tài khoản Readwise của bạn để đồng bộ các phần đánh dấu.",
"Get your access token at": "Lấy mã xác thực tại",
"Access Token": "Mã xác thực",
"Paste your Readwise access token": "Dán mã xác thực Readwise của bạn vào đây",
"Config": "Cấu hình",
"Readwise Sync": "Đồng bộ Readwise",
"Push Highlights": "Đẩy các phần đánh dấu",
"Highlights synced to Readwise": "Các phần đánh dấu đã được đồng bộ với Readwise",
@@ -1136,15 +1127,12 @@
"Drag to seek": "Kéo để tìm kiếm",
"Punctuation Delay": "Độ trễ dấu câu",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "Vui lòng xác nhận rằng kết nối OPDS này sẽ được chuyển tiếp qua máy chủ Readest trên ứng dụng web trước khi tiếp tục.",
"Custom Headers": "Tiêu đề tùy chỉnh",
"Custom Headers (optional)": "Tiêu đề tùy chỉnh (tùy chọn)",
"Add one header per line using \"Header-Name: value\".": "Thêm một tiêu đề mỗi dòng sử dụng \"Header-Name: value\".",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "Tôi hiểu rằng kết nối OPDS này sẽ được chuyển tiếp qua máy chủ Readest trên ứng dụng web. Nếu tôi không tin tưởng Readest với các thông tin đăng nhập hoặc tiêu đề này, tôi nên sử dụng ứng dụng gốc.",
"Unable to connect to Hardcover. Please check your network connection.": "Không thể kết nối với Hardcover. Vui lòng kiểm tra kết nối mạng của bạn.",
"Invalid Hardcover API token": "Token API Hardcover không hợp lệ",
"Disconnected from Hardcover": "Đã ngắt kết nối khỏi Hardcover",
"Hardcover Settings": "Cài đặt Hardcover",
"Connected to Hardcover": "Đã kết nối với Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "Kết nối tài khoản Hardcover của bạn để đồng bộ tiến trình đọc và ghi chú.",
"Get your API token from hardcover.app → Settings → API.": "Lấy token API của bạn từ hardcover.app → Cài đặt → API.",
"API Token": "Token API",
@@ -1374,5 +1362,33 @@
"Credentials": "Thông tin xác thực",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "Mã thông báo, tên người dùng và mật khẩu cho OPDS, KOReader, Hardcover và Readwise. Khi bị tắt, thông tin xác thực chỉ ở trên thiết bị này và không bao giờ được tải lên.",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "Các trường đồng bộ nhạy cảm được mã hóa trước khi tải lên. Đặt cụm mật khẩu ngay bây giờ hoặc sau khi cần mã hóa.",
"Saved to this account. You will be prompted for it when decrypting credentials.": "Đã lưu vào tài khoản này. Bạn sẽ được nhắc nhập khi cần giải mã thông tin xác thực."
"Saved to this account. You will be prompted for it when decrypting credentials.": "Đã lưu vào tài khoản này. Bạn sẽ được nhắc nhập khi cần giải mã thông tin xác thực.",
"Reading progress on this device differs from \"{{deviceName}}\".": "Tiến trình đọc trên thiết bị này khác với \"{{deviceName}}\".",
"This device": "Thiết bị này",
"Set up KOReader Sync…": "Thiết lập Đồng bộ KOReader…",
"Set up Readwise…": "Thiết lập Readwise…",
"Set up Hardcover…": "Thiết lập Hardcover…",
"Red": "Đỏ",
"Yellow": "Vàng",
"Green": "Xanh lá",
"Blue": "Xanh dương",
"Violet": "Tím",
"Edit color": "Chỉnh sửa màu",
"Add custom color": "Thêm màu tùy chỉnh",
"Add label": "Thêm nhãn",
"Choose color": "Chọn màu",
"Connected to Hardcover. Last synced {{time}}.": "Đã kết nối với Hardcover. Đồng bộ lần cuối {{time}}.",
"Integrations": "Tích hợp",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "Đã kết nối với Readwise. Đồng bộ lần cuối {{time}}.",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "Duyệt và tải xuống sách từ các danh mục trực tuyến.",
"Connected as {{user}}": "Đã kết nối với tư cách {{user}}",
"Not connected": "Chưa kết nối",
"{{count}} catalog_other": "{{count}} danh mục",
"No catalogs": "Không có danh mục",
"Connect Readest to external services for sync, highlights, and catalogs.": "Kết nối Readest với các dịch vụ bên ngoài để đồng bộ, đánh dấu và danh mục.",
"Reading Sync": "Đồng bộ Đọc",
"Content Sources": "Nguồn Nội dung",
"Scroll tabs": "Cuộn thẻ"
}
@@ -457,7 +457,6 @@
"Remove from Cloud Only": "仅从云端移除",
"Remove from Device Only": "仅从设备中移除",
"Disconnected": "已断开连接",
"KOReader Sync Settings": "KOReader 同步设置",
"Sync Strategy": "同步策略",
"Ask on conflict": "发生冲突时询问",
"Always use latest": "始终使用最新",
@@ -475,10 +474,7 @@
"Connect": "连接",
"KOReader Sync": "KOReader 同步",
"Sync Conflict": "同步冲突",
"Sync reading progress from \"{{deviceName}}\"?": "从 \"{{deviceName}}\" 同步阅读进度?",
"another device": "另一台设备",
"Local Progress": "本地进度",
"Remote Progress": "远程进度",
"Page {{page}} of {{total}} ({{percentage}}%)": "第 {{page}} 页,共 {{total}} 页 ({{percentage}}%)",
"Current position": "当前位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "大约第 {{page}} 页,共 {{total}} 页 ({{percentage}}%)",
@@ -631,7 +627,6 @@
"Strikethrough": "删除线",
"Squiggly": "波浪线",
"Outline": "轮廓",
"Save Current Color": "保存当前颜色",
"Quick Colors": "快速颜色",
"Highlighter": "荧光笔",
"Save Book Cover": "保存书籍封面",
@@ -1023,16 +1018,12 @@
"Invalid Readwise access token": "无效的 Readwise 访问令牌",
"Disconnected from Readwise": "已断开与 Readwise 的连接",
"Never": "从不",
"Readwise Settings": "Readwise 设置",
"Connected to Readwise": "已连接到 Readwise",
"Last synced: {{time}}": "上次同步时间:{{time}}",
"Sync Enabled": "同步已启用",
"Disconnect": "断开连接",
"Connect your Readwise account to sync highlights.": "连接您的 Readwise 账户以同步高亮内容。",
"Get your access token at": "获取您的访问令牌:",
"Access Token": "访问令牌",
"Paste your Readwise access token": "粘贴您的 Readwise 访问令牌",
"Config": "配置",
"Readwise Sync": "Readwise 同步",
"Push Highlights": "推送高亮内容",
"Highlights synced to Readwise": "高亮内容已同步到 Readwise",
@@ -1136,15 +1127,12 @@
"Drag to seek": "拖动以定位",
"Punctuation Delay": "标点符号停顿",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "继续前请确认此 OPDS 连接将通过网页应用中的 Readest 服务器进行代理。",
"Custom Headers": "自定义标头",
"Custom Headers (optional)": "自定义标头(可选)",
"Add one header per line using \"Header-Name: value\".": "每行添加一个标头,格式为 \"Header-Name: value\"。",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "我了解此 OPDS 连接将通过网页应用中的 Readest 服务器进行代理。如果我不信任 Readest 处理这些凭据或标头,我应该使用原生应用。",
"Unable to connect to Hardcover. Please check your network connection.": "无法连接到 Hardcover。请检查您的网络连接。",
"Invalid Hardcover API token": "无效的 Hardcover API 令牌",
"Disconnected from Hardcover": "已断开与 Hardcover 的连接",
"Hardcover Settings": "Hardcover 设置",
"Connected to Hardcover": "已连接到 Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "连接您的 Hardcover 账户以同步阅读进度和笔记。",
"Get your API token from hardcover.app → Settings → API.": "从 hardcover.app → 设置 → API 获取您的 API 令牌。",
"API Token": "API 令牌",
@@ -1374,5 +1362,33 @@
"Credentials": "凭据",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS、KOReader、Hardcover 和 Readwise 的令牌、用户名和密码。关闭时,凭据仅保留在此设备上,永远不会上传。",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "敏感的同步字段在上传前会被加密。立即设置密码短语,或稍后在需要加密时再设置。",
"Saved to this account. You will be prompted for it when decrypting credentials.": "已保存到此账户。需要解密凭据时会提示您输入。"
"Saved to this account. You will be prompted for it when decrypting credentials.": "已保存到此账户。需要解密凭据时会提示您输入。",
"Reading progress on this device differs from \"{{deviceName}}\".": "此设备上的阅读进度与“{{deviceName}}”不同。",
"This device": "此设备",
"Set up KOReader Sync…": "设置 KOReader 同步…",
"Set up Readwise…": "设置 Readwise…",
"Set up Hardcover…": "设置 Hardcover…",
"Red": "红色",
"Yellow": "黄色",
"Green": "绿色",
"Blue": "蓝色",
"Violet": "紫色",
"Edit color": "编辑颜色",
"Add custom color": "添加自定义颜色",
"Add label": "添加标签",
"Choose color": "选择颜色",
"Connected to Hardcover. Last synced {{time}}.": "已连接到 Hardcover。上次同步 {{time}}。",
"Integrations": "集成",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "已连接到 Readwise。上次同步 {{time}}。",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "浏览并下载在线目录中的图书。",
"Connected as {{user}}": "已以 {{user}} 身份连接",
"Not connected": "未连接",
"{{count}} catalog_other": "{{count}} 个目录",
"No catalogs": "无目录",
"Connect Readest to external services for sync, highlights, and catalogs.": "将 Readest 连接到外部服务以进行同步、高亮和获取目录。",
"Reading Sync": "阅读同步",
"Content Sources": "内容来源",
"Scroll tabs": "滚动选项卡"
}
@@ -456,7 +456,6 @@
"Remove from Cloud Only": "僅從雲端移除",
"Remove from Device Only": "僅從設備中移除",
"Disconnected": "已斷開連接",
"KOReader Sync Settings": "KOReader 同步設置",
"Sync Strategy": "同步策略",
"Ask on conflict": "發生衝突時詢問",
"Always use latest": "始終使用最新",
@@ -474,10 +473,7 @@
"Connect": "連接",
"KOReader Sync": "KOReader 同步",
"Sync Conflict": "同步衝突",
"Sync reading progress from \"{{deviceName}}\"?": "從 \"{{deviceName}}\" 同步閱讀進度?",
"another device": "另一個設備",
"Local Progress": "本地進度",
"Remote Progress": "遠程進度",
"Page {{page}} of {{total}} ({{percentage}}%)": "第 {{page}} 頁,共 {{total}} 頁 ({{percentage}}%)",
"Current position": "當前位置",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "大約第 {{page}} 頁,共 {{total}} 頁 ({{percentage}}%)",
@@ -630,7 +626,6 @@
"Strikethrough": "刪除線",
"Squiggly": "波浪線",
"Outline": "輪廓",
"Save Current Color": "儲存目前顏色",
"Quick Colors": "快速顏色",
"Highlighter": "螢光筆",
"Save Book Cover": "保存書籍封面",
@@ -1022,16 +1017,12 @@
"Invalid Readwise access token": "無效的 Readwise 存取權杖",
"Disconnected from Readwise": "已斷開與 Readwise 的連線",
"Never": "從不",
"Readwise Settings": "Readwise 設定",
"Connected to Readwise": "已連接到 Readwise",
"Last synced: {{time}}": "上次同步時間:{{time}}",
"Sync Enabled": "同步已啟用",
"Disconnect": "斷開連線",
"Connect your Readwise account to sync highlights.": "連接您的 Readwise 帳戶以同步高亮內容。",
"Get your access token at": "獲取您的存取權杖:",
"Access Token": "存取權杖",
"Paste your Readwise access token": "貼上您的 Readwise 存取權杖",
"Config": "配置",
"Readwise Sync": "Readwise 同步",
"Push Highlights": "推送高亮內容",
"Highlights synced to Readwise": "高亮內容已同步到 Readwise",
@@ -1136,15 +1127,12 @@
"Drag to seek": "拖曳以定位",
"Punctuation Delay": "標點符號停頓",
"Please confirm that this OPDS connection will be proxied through Readest servers on the web app before continuing.": "繼續前請確認此 OPDS 連線將透過網頁應用程式中的 Readest 伺服器進行代理。",
"Custom Headers": "自訂標頭",
"Custom Headers (optional)": "自訂標頭(選填)",
"Add one header per line using \"Header-Name: value\".": "每行新增一個標頭,格式為 \"Header-Name: value\"。",
"I understand this OPDS connection will be proxied through Readest servers on the web app. If I do not trust Readest with these credentials or headers, I should use the native app instead.": "我了解此 OPDS 連線將透過網頁應用程式中的 Readest 伺服器進行代理。若我不信任 Readest 處理這些憑證或標頭,我應該使用原生應用程式。",
"Unable to connect to Hardcover. Please check your network connection.": "無法連線到 Hardcover。請檢查您的網路連線。",
"Invalid Hardcover API token": "無效的 Hardcover API 權杖",
"Disconnected from Hardcover": "已中斷與 Hardcover 的連線",
"Hardcover Settings": "Hardcover 設定",
"Connected to Hardcover": "已連線到 Hardcover",
"Connect your Hardcover account to sync reading progress and notes.": "連結您的 Hardcover 帳戶以同步閱讀進度和筆記。",
"Get your API token from hardcover.app → Settings → API.": "從 hardcover.app → 設定 → API 取得您的 API 權杖。",
"API Token": "API 權杖",
@@ -1374,5 +1362,33 @@
"Credentials": "憑證",
"Tokens, usernames, and passwords for OPDS, KOReader, Hardcover, and Readwise. When disabled, credentials remain on this device only and are never uploaded.": "OPDS、KOReader、Hardcover 與 Readwise 的權杖、使用者名稱與密碼。停用時,憑證僅留在此裝置上,永不上傳。",
"Sensitive synced fields are encrypted before upload. Set a passphrase now or later when encryption is needed.": "敏感的同步欄位會在上傳前加密。立即設定密碼短語,或稍後在需要加密時再設定。",
"Saved to this account. You will be prompted for it when decrypting credentials.": "已儲存到此帳號。需要解密憑證時會提示您輸入。"
"Saved to this account. You will be prompted for it when decrypting credentials.": "已儲存到此帳號。需要解密憑證時會提示您輸入。",
"Reading progress on this device differs from \"{{deviceName}}\".": "此裝置上的閱讀進度與「{{deviceName}}」不同。",
"This device": "此裝置",
"Set up KOReader Sync…": "設定 KOReader 同步…",
"Set up Readwise…": "設定 Readwise…",
"Set up Hardcover…": "設定 Hardcover…",
"Red": "紅色",
"Yellow": "黃色",
"Green": "綠色",
"Blue": "藍色",
"Violet": "紫色",
"Edit color": "編輯顏色",
"Add custom color": "新增自訂顏色",
"Add label": "新增標籤",
"Choose color": "選擇顏色",
"Connected to Hardcover. Last synced {{time}}.": "已連線至 Hardcover。上次同步 {{time}}。",
"Integrations": "整合",
"Hardcover": "Hardcover",
"Connected to Readwise. Last synced {{time}}.": "已連線至 Readwise。上次同步 {{time}}。",
"Readwise": "Readwise",
"Browse and download books from online catalogs.": "瀏覽並下載線上目錄中的書籍。",
"Connected as {{user}}": "已以 {{user}} 身分連線",
"Not connected": "未連線",
"{{count}} catalog_other": "{{count}} 個目錄",
"No catalogs": "無目錄",
"Connect Readest to external services for sync, highlights, and catalogs.": "將 Readest 連線至外部服務以進行同步、標示與取得目錄。",
"Reading Sync": "閱讀同步",
"Content Sources": "內容來源",
"Scroll tabs": "捲動分頁"
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"impeccable": {
"source": "pbakaus/impeccable",
"sourceType": "github",
"skillPath": "skill/SKILL.md",
"computedHash": "8cdf2e788eaca3af7d02026ef9cec55b506100a00d130f938e3fb1ad669c0ad0"
}
}
}
@@ -80,15 +80,17 @@ describe('HighlightColorsEditor — user color SketchPicker stability', () => {
it('keeps the SketchPicker mounted when the user-color hex updates (so drag is not interrupted)', () => {
render(<Harness initialUserColors={[{ hex: '#aabbcc' }]} />);
// The user color row's ColorInput renders a text input with the hex.
// Find it (the predefined palette also renders inputs; the user row's
// input is last in the document because it's rendered after the defaults).
const hexInputs = screen.getAllByDisplayValue(/^#/);
const userHexInput = hexInputs[hexInputs.length - 1]!;
expect(userHexInput).toHaveProperty('value', '#aabbcc');
// ColorInput swatchOnly renders a circular button per color, all with
// aria-label "Edit color". Default palette is rendered first, then the
// "add new" swatch in the Custom Colors header (aria-label "Add custom
// color"), then the existing user colors. Find the last "Edit color"
// button — that's the user color we care about.
const editColorButtons = screen.getAllByLabelText('Edit color');
const userSwatch = editColorButtons[editColorButtons.length - 1]! as HTMLButtonElement;
expect(userSwatch.style.backgroundColor).toBe('rgb(170, 187, 204)');
// Open the SketchPicker for this user color.
fireEvent.click(userHexInput);
fireEvent.click(userSwatch);
const picker = screen.getByTestId('mock-sketch-picker');
const initialMountId = picker.getAttribute('data-mount-id');
@@ -5,6 +5,8 @@ vi.mock('react-icons/ri', () => ({
RiFontSize: () => null,
RiDashboardLine: () => null,
RiTranslate: () => null,
RiPlugLine: () => null,
RiShareLine: () => null,
}));
vi.mock('react-icons/vsc', () => ({
VscSymbolColor: () => null,
@@ -2,27 +2,31 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
IoAdd,
IoTrash,
IoOpenOutline,
IoBook,
IoEllipsisVertical,
IoEyeOff,
IoEye,
IoPencil,
IoCloudDownloadOutline,
} from 'react-icons/io5';
import { MdChevronRight } from 'react-icons/md';
import Dropdown from '@/components/Dropdown';
import Menu from '@/components/Menu';
import MenuItem from '@/components/MenuItem';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
import { useCustomOPDSStore } from '@/store/customOPDSStore';
import { ensurePassphraseUnlocked } from '@/services/sync/passphraseGate';
import { isCredentialsSyncEnabled } from '@/services/sync/syncCategories';
import { isSyncError } from '@/libs/errors';
import { OPDSCatalog } from '@/types/opds';
import { isLanAddress } from '@/utils/network';
import { eventDispatcher } from '@/utils/event';
import { SectionTitle } from '@/components/settings/primitives';
import { deleteSubscriptionState, loadSubscriptionState } from '@/services/opds';
import type { OPDSSubscriptionState } from '@/services/opds/types';
import { validateOPDSURL } from '../utils/opdsUtils';
@@ -75,6 +79,14 @@ async function validateOPDSCatalog(
return { valid: result.isValid, error: result.error };
}
/**
* Debounce window for the auto-download enable trigger. Toggling the switch
* on schedules the `check-opds-subscriptions` dispatch via setTimeout;
* toggling off within this window cancels the pending dispatch. Gives users
* a chance to undo an accidental enable before any actual download starts.
*/
const AUTO_DOWNLOAD_DEBOUNCE_MS = 5000;
const EMPTY_NEW_CATALOG = {
name: '',
url: '',
@@ -86,7 +98,17 @@ const EMPTY_NEW_CATALOG = {
autoDownload: false,
};
export function CatalogManager() {
interface CatalogManagerProps {
/**
* When true, the panel title block (h1 + description) is hidden because
* the host renders its own header (e.g. SubPageHeader inside Settings
* Integrations). The OPDS dialog and standalone /opds page leave this off
* so the title shows.
*/
inSubPage?: boolean;
}
export function CatalogManager({ inSubPage = false }: CatalogManagerProps = {}) {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
@@ -219,8 +241,13 @@ export function CatalogManager() {
// turns the credentials into actual cross-device sync. User
// cancel = save proceeds without sync (the catalog still works
// locally with the entered creds).
//
// Skip the prompt entirely when credentials sync is disabled — in
// that mode the creds stay device-local by design and never need
// the passphrase, so prompting would be both pointless and
// confusing (Settings → Sync → Credentials toggle).
const hasCredentials = !!(newCatalog.username || newCatalog.password);
if (hasCredentials) {
if (hasCredentials && isCredentialsSyncEnabled()) {
try {
await ensurePassphraseUnlocked();
} catch (err) {
@@ -300,22 +327,58 @@ export function CatalogManager() {
}
};
// Per-catalog pending timeouts for the debounced auto-download trigger.
// Each catalog's enable schedules its own timer; toggling off cancels just
// that catalog's pending dispatch (other catalogs' timers are untouched).
const pendingAutoDownloadTimeouts = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// Clear any pending auto-download timers when the panel unmounts so we
// don't fire dispatches against a torn-down React tree.
useEffect(() => {
const timeouts = pendingAutoDownloadTimeouts.current;
return () => {
timeouts.forEach(clearTimeout);
timeouts.clear();
};
}, []);
const handleToggleAutoDownload = (id: string) => {
const target = catalogs.find((c) => c.id === id);
if (!target) return;
const wasEnabled = !!target.autoDownload;
useCustomOPDSStore.getState().updateCatalog(id, { autoDownload: !wasEnabled });
persistMutation();
// When the user just enabled auto-download, sync now instead of waiting
// for the next app launch / pull-to-refresh.
// Cancel any pending sync trigger for this catalog — covers both:
// - rapid on/off toggles (user clicked by accident, reverted in time)
// - on → off → on (a fresh debounce window starts on the next enable)
const pending = pendingAutoDownloadTimeouts.current.get(id);
if (pending) {
clearTimeout(pending);
pendingAutoDownloadTimeouts.current.delete(id);
}
// When enabling, schedule the sync after the debounce window. If the user
// toggles off again within the window, the cancel above intercepts the
// dispatch so no download starts.
if (!wasEnabled) {
eventDispatcher.dispatch('check-opds-subscriptions');
const timeoutId = setTimeout(() => {
pendingAutoDownloadTimeouts.current.delete(id);
eventDispatcher.dispatch('check-opds-subscriptions');
}, AUTO_DOWNLOAD_DEBOUNCE_MS);
pendingAutoDownloadTimeouts.current.set(id, timeoutId);
}
};
const handleOpenCatalog = (catalog: OPDSCatalog) => {
const params = new URLSearchParams({ url: catalog.url });
params.set('id', catalog.id);
// When opened from inside Settings → Integrations → OPDS Catalogs,
// tag the URL so the browser's close handler can return us here
// instead of falling back to the standalone library OPDS dialog.
if (inSubPage) {
params.set('from', 'settings-integrations');
}
router.push(`/opds?${params.toString()}`);
};
@@ -331,26 +394,31 @@ export function CatalogManager() {
return (
<div className='container max-w-2xl'>
<div className='mb-8'>
<h1 className='mb-2 text-base font-bold'>{_('OPDS Catalogs')}</h1>
<p className='text-base-content/70 text-xs'>
{_('Browse and download books from online catalogs')}
</p>
</div>
{!inSubPage && (
<div className='mb-8'>
<h1 className='mb-1.5 text-lg font-semibold tracking-tight'>{_('OPDS Catalogs')}</h1>
<p className='text-base-content/70 text-sm leading-relaxed'>
{_('Browse and download books from online catalogs')}
</p>
</div>
)}
{/* My Catalogs */}
<section className='mb-12 text-base'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='font-semibold'>{_('My Catalogs')}</h2>
<button onClick={() => setShowAddDialog(true)} className='btn btn-primary btn-sm'>
<section className='mb-10 text-base'>
<div className='mb-3 flex items-center justify-between'>
<SectionTitle>{_('My Catalogs')}</SectionTitle>
<button
onClick={() => setShowAddDialog(true)}
className='eink-bordered border-base-200 hover:border-base-300 hover:bg-base-200/60 focus-visible:ring-base-content/15 inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2'
>
<IoAdd className='h-4 w-4' />
{_('Add Catalog')}
</button>
</div>
{catalogs.length === 0 ? (
<div className='border-base-300 rounded-lg border-2 border-dashed p-12 text-center'>
<IoBook className='text-base-content/30 mx-auto mb-4 h-12 w-12' />
<div className='eink-bordered border-base-300 rounded-lg border-2 border-dashed p-12 text-center'>
<IoBook className='text-base-content/40 mx-auto mb-4 h-12 w-12' />
<h3 className='mb-2 font-semibold'>{_('No catalogs yet')}</h3>
<p className='text-base-content/70 mb-4 text-sm'>
{_('Add your first OPDS catalog to start browsing books')}
@@ -360,125 +428,171 @@ export function CatalogManager() {
</button>
</div>
) : (
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
{catalogs.map((catalog) => (
<div
key={catalog.id}
className='card bg-base-100 border-base-300 h-full border shadow-sm transition-shadow hover:shadow-md'
>
<div className='card-body h-full justify-between p-4'>
<div className='flex items-center justify-between'>
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center justify-between'>
<h3 className='card-title text-sm'>
{catalog.icon && <span>{catalog.icon}</span>}
<span className='line-clamp-1'>{catalog.name}</span>
</h3>
<div className='flex gap-1'>
<button
onClick={() => handleEditCatalog(catalog)}
className='btn btn-ghost btn-xs btn-square'
title={_('Edit')}
>
<IoPencil className='h-4 w-4' />
</button>
<button
onClick={() => handleRemoveCatalog(catalog.id)}
className='btn btn-ghost btn-xs btn-square'
title={_('Remove')}
>
<IoTrash className='h-4 w-4' />
</button>
</div>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
{catalogs.map((catalog) => {
const subState = subscriptionStates[catalog.id];
const lastCheckedAt = subState?.lastCheckedAt ?? 0;
const failedCount = subState?.failedEntries.length ?? 0;
const showSubscriptionStatus =
catalog.autoDownload && subState && (lastCheckedAt > 0 || failedCount > 0);
return (
// Whole card is the browse trigger. Uses role='button' (not
// a real <button>) because it nests other interactive
// elements: the 3-dot menu, auto-download toggle, and
// failed-downloads link. Inner controls call
// e.stopPropagation() so their clicks don't bubble.
<div
key={catalog.id}
role='button'
tabIndex={catalog.disabled ? -1 : 0}
onClick={() => !catalog.disabled && handleOpenCatalog(catalog)}
onKeyDown={(e) => {
if (catalog.disabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpenCatalog(catalog);
}
}}
className={clsx(
'card eink-bordered bg-base-100 border-base-200 group/card flex flex-col border transition-colors duration-150',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
catalog.disabled
? 'cursor-not-allowed opacity-60'
: 'hover:bg-base-200/40 cursor-pointer',
)}
>
<div className='flex flex-1 flex-col gap-2.5 px-4 pb-2 pt-4'>
{/* Header: icon + name + chevron hint (whole card is
the click target) | overflow menu (Edit / Remove). */}
<div className='flex items-start justify-between gap-2'>
<h4 className='flex min-w-0 flex-1 items-center gap-1.5 text-sm font-semibold'>
{catalog.icon && <span className='flex-shrink-0'>{catalog.icon}</span>}
<span className='truncate'>{catalog.name}</span>
</h4>
{/* stopPropagation on the trigger wrapper so opening
the menu doesn't also browse the catalog.
The Dropdown component itself handles floating the
menu via daisyui's `.dropdown .dropdown-content`
position:absolute rule don't add !relative here
or the menu inlines into the card layout. */}
<div
className='-mr-1.5 -mt-1 flex-shrink-0'
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Dropdown
label={_('Catalog actions')}
className='dropdown-bottom dropdown-end'
buttonClassName='text-base-content/55 hover:bg-base-200 hover:text-base-content focus-visible:ring-base-content/15 flex h-7 w-7 items-center justify-center rounded-md transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2'
toggleButton={<IoEllipsisVertical className='h-4 w-4' />}
>
<Menu className='dropdown-content no-triangle border-base-300 z-20 mt-1 min-w-[8rem] rounded-lg border shadow-lg'>
<MenuItem
noIcon
transient
label={_('Edit')}
onClick={() => handleEditCatalog(catalog)}
/>
<MenuItem
noIcon
transient
label={_('Remove')}
onClick={() => handleRemoveCatalog(catalog.id)}
/>
</Menu>
</Dropdown>
</div>
{catalog.description && (
<p className='text-base-content/70 mb-2 line-clamp-1 h-6 text-sm sm:line-clamp-2 sm:h-10'>
{catalog.description}
</p>
)}
<p className='text-base-content/50 line-clamp-1 text-xs'>{catalog.url}</p>
{catalog.username && (
<p className='text-base-content/50 mt-1 text-xs'>
{_('Username')}: {catalog.username}
</p>
)}
{hasOPDSCustomHeaders(catalog.customHeaders) && (
<p className='text-base-content/50 mt-1 text-xs'>
{_('Custom Headers')}: {Object.keys(catalog.customHeaders || {}).length}
</p>
)}
</div>
{/* Description (optional) single line in My Catalogs
to keep cards compact and consistent in height
regardless of description length. */}
{catalog.description && (
<p className='text-base-content/70 line-clamp-1 text-xs leading-relaxed'>
{catalog.description}
</p>
)}
{/* URL — quieter, mono-ish */}
<p className='text-base-content/55 truncate text-[11px]' title={catalog.url}>
{catalog.url}
</p>
{/* Auto-download row label and toggle live in a SAME
flex line (items-center vertically centered with
each other). Subline sits beneath as a sibling.
The subline always renders (with &nbsp; placeholder
when no status data) so the row's total height stays
constant toggling AD on/off or sync-status data
arriving via opds-sync-complete never shifts the
card. Browse is the whole-card click; stopPropagation
on the label so toggling doesn't also browse. */}
<div className='mt-auto flex flex-col gap-0.5'>
<label
onClick={(e) => e.stopPropagation()}
className={clsx(
'flex items-center justify-between gap-2',
catalog.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
)}
>
<span className='text-base-content/80 inline-flex items-center gap-1.5 text-xs'>
<IoCloudDownloadOutline className='h-3.5 w-3.5' />
{_('Auto-download')}
</span>
<input
type='checkbox'
className='toggle toggle-sm toggle-primary flex-shrink-0'
checked={!!catalog.autoDownload}
disabled={!!catalog.disabled}
onChange={() => handleToggleAutoDownload(catalog.id)}
/>
</label>
<span className='text-base-content/55 truncate text-[11px] leading-tight'>
{showSubscriptionStatus ? (
<>
{lastCheckedAt > 0 && (
<span>
{_('Last synced {{when}}', {
when: dayjs(lastCheckedAt).fromNow(),
})}
</span>
)}
{failedCount > 0 && (
<>
{lastCheckedAt > 0 && <span aria-hidden> · </span>}
<button
type='button'
onClick={(e) => {
e.stopPropagation();
setFailedDialogCatalogId(catalog.id);
}}
className='text-error hover:underline'
>
{_('{{count}} failed', { count: failedCount })}
</button>
</>
)}
</>
) : (
// &nbsp; reserves line-height so the row above
// stays anchored at a consistent vertical position.
<>&nbsp;</>
)}
</span>
</div>
</div>
<div className='mt-2 flex items-center gap-2'>
<label
className={clsx(
'label gap-2 p-0',
catalog.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
)}
>
<input
type='checkbox'
className='toggle toggle-sm toggle-primary'
checked={!!catalog.autoDownload}
disabled={!!catalog.disabled}
onChange={() => handleToggleAutoDownload(catalog.id)}
/>
<span className='label-text text-xs'>
<IoCloudDownloadOutline className='mr-1 inline h-3.5 w-3.5' />
{_('Auto-download')}
</span>
</label>
</div>
{(() => {
const subState = subscriptionStates[catalog.id];
if (!catalog.autoDownload || !subState) return null;
const lastCheckedAt = subState.lastCheckedAt;
const failedCount = subState.failedEntries.length;
if (lastCheckedAt === 0 && failedCount === 0) return null;
return (
<div className='text-base-content/60 mt-1 flex items-center gap-2 text-xs'>
{lastCheckedAt > 0 && (
<span>
{_('Last synced {{when}}', {
when: dayjs(lastCheckedAt).fromNow(),
})}
</span>
)}
{failedCount > 0 && (
<>
{lastCheckedAt > 0 && <span aria-hidden>·</span>}
<button
type='button'
onClick={() => setFailedDialogCatalogId(catalog.id)}
className='text-error hover:underline'
>
{_('{{count}} failed', { count: failedCount })}
</button>
</>
)}
</div>
);
})()}
<div className='card-actions mt-4 justify-end'>
<button
onClick={() => handleOpenCatalog(catalog)}
className='btn btn-sm btn-primary'
>
<IoOpenOutline className='h-4 w-4' />
{_('Browse')}
</button>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</section>
{/* Popular Catalogs */}
<section className={clsx('text-base', popularCatalogs.length === 0 && 'hidden')}>
<h2 className='mb-4 font-semibold'>{_('Popular Catalogs')}</h2>
<div className='grid gap-4 sm:grid-cols-2'>
<SectionTitle className='mb-3'>{_('Popular Catalogs')}</SectionTitle>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
{popularCatalogs
.filter((catalog) => !catalog.disabled)
.map((catalog) => {
@@ -486,23 +600,29 @@ export function CatalogManager() {
return (
<div
key={catalog.id}
className='card bg-base-100 border-base-300 border shadow-sm transition-shadow hover:shadow-md'
className='card eink-bordered bg-base-100 border-base-200 flex flex-col border'
>
<div className='card-body p-4'>
<h3 className='card-title mb-1 text-sm'>
{catalog.icon && <span>{catalog.icon}</span>}
{catalog.name}
</h3>
<div className='flex flex-1 flex-col gap-2.5 p-4'>
<h4>
<button
type='button'
onClick={() => handleOpenCatalog(catalog)}
className='flex w-full min-w-0 items-center gap-1.5 rounded-sm text-start text-sm font-semibold transition-colors duration-150 hover:underline focus-visible:underline focus-visible:outline-none'
>
{catalog.icon && <span className='flex-shrink-0'>{catalog.icon}</span>}
<span className='truncate'>{catalog.name}</span>
</button>
</h4>
{catalog.description && (
<p className='text-base-content/70 line-clamp-2 text-sm'>
<p className='text-base-content/70 line-clamp-2 text-xs leading-relaxed'>
{catalog.description}
</p>
)}
<div className='card-actions mt-4 justify-end gap-2'>
<div className='border-base-200 mt-auto flex items-center justify-end gap-1 border-t pt-3'>
{!isAdded && (
<button
onClick={() => handleAddPopularCatalog(catalog)}
className='btn btn-sm'
className='hover:bg-base-200 focus-visible:ring-base-content/15 inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2'
>
<IoAdd className='h-4 w-4' />
{_('Add')}
@@ -510,10 +630,10 @@ export function CatalogManager() {
)}
<button
onClick={() => handleOpenCatalog(catalog)}
className='btn btn-sm btn-primary'
className='hover:bg-base-200 focus-visible:ring-base-content/15 inline-flex items-center gap-0.5 rounded-md px-2 py-1 text-xs font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2'
>
<IoOpenOutline className='h-4 w-4' />
{_('Browse')}
<MdChevronRight className='h-4 w-4' />
</button>
</div>
</div>
@@ -528,7 +648,7 @@ export function CatalogManager() {
<ModalPortal>
<dialog className='modal modal-open'>
<div className='modal-box'>
<h3 className='mb-4 text-lg font-bold'>
<h3 className='mb-4 text-lg font-semibold tracking-tight'>
{editingCatalogId ? _('Edit OPDS Catalog') : _('Add OPDS Catalog')}
</h3>
<form
@@ -547,7 +667,7 @@ export function CatalogManager() {
value={newCatalog.name}
onChange={(e) => setNewCatalog({ ...newCatalog, name: e.target.value })}
placeholder={_('My Calibre Library')}
className='input input-bordered placeholder:text-sm'
className='input input-bordered eink-bordered placeholder:text-sm'
disabled={isValidating}
required
/>
@@ -565,7 +685,7 @@ export function CatalogManager() {
setUrlError('');
}}
placeholder='https://example.com/opds'
className='input input-bordered placeholder:text-sm'
className='input input-bordered eink-bordered placeholder:text-sm'
disabled={isValidating}
required
/>
@@ -588,7 +708,7 @@ export function CatalogManager() {
setProxyConsentError('');
}}
placeholder={_('Username')}
className='input input-bordered placeholder:text-sm'
className='input input-bordered eink-bordered placeholder:text-sm'
disabled={isValidating}
autoComplete='username'
/>
@@ -607,7 +727,7 @@ export function CatalogManager() {
setProxyConsentError('');
}}
placeholder={_('Password')}
className='input input-bordered w-full pr-10 placeholder:text-sm'
className='input input-bordered eink-bordered w-full pr-10 placeholder:text-sm'
disabled={isValidating}
autoComplete='current-password'
/>
@@ -641,7 +761,7 @@ export function CatalogManager() {
'CF-Access-Client-Id': 'your-client-id',
'CF-Access-Client-Secret': 'your-client-secret',
})}
className='textarea textarea-bordered font-mono text-sm placeholder:text-xs'
className='textarea textarea-bordered eink-bordered font-mono text-sm placeholder:text-xs'
rows={4}
disabled={isValidating}
spellCheck={false}
@@ -693,7 +813,7 @@ export function CatalogManager() {
value={newCatalog.description}
onChange={(e) => setNewCatalog({ ...newCatalog, description: e.target.value })}
placeholder={_('A brief description of this catalog')}
className='textarea textarea-bordered text-sm placeholder:text-sm'
className='textarea textarea-bordered eink-bordered text-sm placeholder:text-sm'
rows={2}
disabled={isValidating}
/>
@@ -719,16 +839,33 @@ export function CatalogManager() {
</label>
</div>
<div className='modal-action'>
<div className='modal-action gap-3'>
<button
type='button'
onClick={handleCloseDialog}
className='btn'
disabled={isValidating}
className={clsx(
'eink-bordered',
'h-10 rounded-lg px-4 text-sm font-medium',
'text-base-content hover:bg-base-200',
'transition-colors duration-150',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
'disabled:cursor-not-allowed disabled:opacity-60',
'disabled:hover:bg-transparent',
)}
>
{_('Cancel')}
</button>
<button type='submit' className='btn btn-primary' disabled={isValidating}>
<button
type='submit'
disabled={isValidating}
className={clsx(
'btn btn-primary',
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
isValidating && 'opacity-60',
)}
>
{isValidating ? (
<>
<span className='loading loading-spinner loading-sm'></span>
@@ -2,7 +2,7 @@
import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { IoMdCloseCircle } from 'react-icons/io';
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
@@ -10,9 +10,9 @@ import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useSettingsStore } from '@/store/settingsStore';
import { navigateToLibrary } from '@/utils/nav';
import { debounce } from '@/utils/debounce';
import WindowButtons from '@/components/WindowButtons';
import { closeOPDSBrowser } from '../utils/opdsClose';
interface NavigationProps {
searchTerm?: string;
@@ -37,6 +37,7 @@ export function Navigation({
}: NavigationProps) {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const viewSettings = settings.globalViewSettings;
@@ -56,8 +57,8 @@ export function Navigation({
}, [hasSearch]);
const handleGoLibrary = useCallback(() => {
navigateToLibrary(router, 'opds=true', {}, true);
}, [router]);
closeOPDSBrowser(router, searchParams);
}, [router, searchParams]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdateQueryParam = useCallback(
+8
View File
@@ -45,6 +45,7 @@ import { PublicationView } from './components/PublicationView';
import { SearchView } from './components/SearchView';
import { Navigation } from './components/Navigation';
import { normalizeOPDSCustomHeaders } from './utils/customHeaders';
import { stashOPDSReturnTarget } from './utils/opdsClose';
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
@@ -200,6 +201,11 @@ export default function BrowserPage() {
type: 'error',
});
setTimeout(() => {
// router.back() (not closeOPDSBrowser) so the user can
// resume their browser history if the catalog was just
// temporarily down. stashOPDSReturnTarget ensures the
// deep-link still applies when back lands on /library.
stashOPDSReturnTarget(searchParams);
router.back();
}, 5000);
throw new Error(errorMessage);
@@ -269,6 +275,7 @@ export default function BrowserPage() {
const htmlDoc = new DOMParser().parseFromString(text, type as DOMParserSupportedType);
if (!htmlDoc.head) {
stashOPDSReturnTarget(searchParams);
router.back();
throw new Error(`Failed to load OPDS feed: ${res.status} ${res.statusText}`);
}
@@ -278,6 +285,7 @@ export default function BrowserPage() {
);
if (!link) {
stashOPDSReturnTarget(searchParams);
router.back();
throw new Error('Document has no link to OPDS feeds');
}
@@ -0,0 +1,54 @@
import type { ReadonlyURLSearchParams, useRouter } from 'next/navigation';
import { navigateToLibrary } from '@/utils/nav';
import { useSettingsStore } from '@/store/settingsStore';
/**
* If the OPDS browser was opened from inside Settings Integrations
* OPDS Catalogs (CatalogManager tags the URL with `from=
* settings-integrations`), set the deep-link store fields so that when
* the library page re-mounts and the SettingsDialog re-opens, the
* IntegrationsPanel drills into the OPDS Catalogs sub-page rather than
* landing on the panel's top level.
*
* Call this **before** the navigation that returns to /library works
* with both `router.back()` (used by failure paths so the user can resume
* their browser history) and `navigateToLibrary` (used by the manual
* close button which doesn't need history-back semantics).
*/
export const stashOPDSReturnTarget = (searchParams: ReadonlyURLSearchParams | null) => {
if (searchParams?.get('from') !== 'settings-integrations') return;
const { setRequestedPanel, setRequestedSubPage, setSettingsDialogOpen } =
useSettingsStore.getState();
setRequestedPanel('Integrations');
setRequestedSubPage('opds');
setSettingsDialogOpen(true);
};
/**
* Close the OPDS browser and return to wherever the user came from.
*
* - When `from=settings-integrations`: stash the deep-link target and
* navigate to /library. Library page mounts with `isSettingsDialogOpen`
* true (preserved in zustand), the dialog re-mounts on Integrations,
* and the panel reads `requestedSubPage='opds'` to land back at the
* catalog list.
* - Otherwise, navigate to the library with `?opds=true` so the standalone
* OPDS dialog opens. This matches the original close behavior for users
* who entered the browser via the library's own OPDS button.
*
* Used by the manual close button (Navigation). Failure paths in page.tsx
* use `stashOPDSReturnTarget` + `router.back()` instead so the user can
* resume their browser history (e.g. retry the catalog or step further
* back) `navigateToLibrary` would clobber that history.
*/
export const closeOPDSBrowser = (
router: ReturnType<typeof useRouter>,
searchParams: ReadonlyURLSearchParams | null,
) => {
if (searchParams?.get('from') === 'settings-integrations') {
stashOPDSReturnTarget(searchParams);
navigateToLibrary(router, '', undefined, true);
return;
}
navigateToLibrary(router, 'opds=true', {}, true);
};
@@ -1,166 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import { HardcoverClient, HardcoverSyncMapStore } from '@/services/hardcover';
import Dialog from '@/components/Dialog';
export const setHardcoverSettingsWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('hardcover_settings_window');
if (dialog) {
const event = new CustomEvent('setHardcoverSettingsVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const HardcoverSettingsWindow: React.FC = () => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [isOpen, setIsOpen] = useState(false);
const [accessToken, setAccessToken] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const isConfigured = !!settings.hardcover?.accessToken;
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
setAccessToken('');
}
};
const el = document.getElementById('hardcover_settings_window');
el?.addEventListener('setHardcoverSettingsVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setHardcoverSettingsVisibility', handleCustomEvent as EventListener);
};
}, []);
const handleConnect = async () => {
setIsConnecting(true);
try {
const appService = await envConfig.getAppService();
const mapStore = new HardcoverSyncMapStore(appService);
const client = new HardcoverClient({ accessToken }, mapStore);
const { valid, isNetworkError } = await client.validateToken();
if (valid) {
const newSettings = {
...settings,
hardcover: {
enabled: true,
accessToken,
lastSyncedAt: settings.hardcover?.lastSyncedAt ?? 0,
},
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
} else if (isNetworkError) {
eventDispatcher.dispatch('toast', {
message: _('Unable to connect to Hardcover. Please check your network connection.'),
type: 'error',
});
} else {
eventDispatcher.dispatch('toast', {
message: _('Invalid Hardcover API token'),
type: 'error',
});
}
} finally {
setIsConnecting(false);
setAccessToken('');
}
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
hardcover: { enabled: false, accessToken: '', lastSyncedAt: 0 },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
eventDispatcher.dispatch('toast', { message: _('Disconnected from Hardcover'), type: 'info' });
};
const handleToggleEnabled = async () => {
const newSettings = {
...settings,
hardcover: { ...settings.hardcover, enabled: !settings.hardcover?.enabled },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const lastSyncedAt = settings.hardcover?.lastSyncedAt ?? 0;
const lastSyncedLabel = lastSyncedAt ? new Date(lastSyncedAt).toLocaleString() : _('Never');
return (
<Dialog
id='hardcover_settings_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Hardcover Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<>
<div className='text-center'>
<p className='text-base-content/80 text-sm'>{_('Connected to Hardcover')}</p>
<p className='text-base-content/60 mt-1 text-xs'>
{_('Last synced: {{time}}', { time: lastSyncedLabel })}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>{_('Sync Enabled')}</span>
<input
type='checkbox'
className='toggle'
checked={settings.hardcover?.enabled ?? false}
onChange={handleToggleEnabled}
/>
</div>
<button className='btn btn-outline btn-sm mt-2' onClick={handleDisconnect}>
{_('Disconnect')}
</button>
</>
) : (
<>
<p className='text-base-content/70 text-center text-sm'>
{_('Connect your Hardcover account to sync reading progress and notes.')}
</p>
<p className='text-base-content/60 text-center text-xs'>
{_('Get your API token from hardcover.app → Settings → API.')}
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('API Token')}</span>
</label>
<input
type='password'
placeholder={_('Paste your Hardcover API token')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
/>
</div>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
disabled={isConnecting || !accessToken}
>
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
</button>
</>
)}
</div>
)}
</Dialog>
);
};
@@ -1,6 +1,8 @@
import clsx from 'clsx';
import React from 'react';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { SectionTitle } from '@/components/settings/primitives';
import { SyncDetails } from '../hooks/useKOSync';
interface KOSyncConflictResolverProps {
@@ -20,29 +22,63 @@ const KOSyncConflictResolver: React.FC<KOSyncConflictResolverProps> = ({
if (!details) return null;
const remoteDeviceName = details.remote.device || _('another device');
return (
<Dialog isOpen={true} onClose={onClose} title={_('Sync Conflict')}>
<p className='py-4 text-center'>
{_('Sync reading progress from "{{deviceName}}"?', {
deviceName: details.remote.device || _('another device'),
<p className='text-base-content/70 mb-5 mt-1 px-1 text-center text-sm leading-relaxed'>
{_('Reading progress on this device differs from "{{deviceName}}".', {
deviceName: remoteDeviceName,
})}
</p>
<div className='mt-4 space-y-4'>
<div className='flex flex-col gap-2.5'>
<button
className='btn h-auto w-full flex-col items-start py-2'
type='button'
onClick={onResolveWithLocal}
className={clsx(
'eink-bordered group',
'flex w-full items-start gap-3 rounded-xl text-left',
'border-base-200 bg-base-100 border px-4 py-3.5',
'transition-colors duration-150',
'hover:border-base-300 hover:bg-base-200/60',
'active:bg-base-200/80',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
)}
>
<span>{_('Local Progress')}</span>
<span className='text-base-content/50 text-xs font-normal normal-case'>
{details.local.preview}
</span>
<div className='flex min-w-0 flex-1 flex-col gap-1'>
{/* SectionTitle gives the caseless-language size bump for free.
`as='span'` because this lives inside a button, not as a
document heading; opacity override expresses the
"secondary on this surface" relationship. */}
<SectionTitle as='span' className='!text-base-content/55'>
{_('This device')}
</SectionTitle>
<span className='line-clamp-2 text-sm font-medium leading-snug'>
{details.local.preview}
</span>
</div>
</button>
<button
className='btn btn-primary h-auto w-full flex-col items-start py-2'
type='button'
onClick={onResolveWithRemote}
className={clsx(
'btn btn-primary group',
'h-auto min-h-0 w-full justify-start gap-3',
'rounded-xl border-0 px-4 py-3.5 text-left font-normal normal-case',
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
)}
>
<span>{_('Remote Progress')}</span>
<span className='text-xs font-normal normal-case'>{details.remote.preview}</span>
<div className='flex min-w-0 flex-1 flex-col items-start gap-1'>
{/* On the primary-color button background the default
/65 token would clash; opacity-75 inherits the button's
contrast color and dims it uniformly. */}
<SectionTitle as='span' className='!text-current opacity-75'>
{remoteDeviceName}
</SectionTitle>
<span className='line-clamp-2 text-sm font-medium leading-snug'>
{details.remote.preview}
</span>
</div>
</button>
</div>
</Dialog>
@@ -1,343 +0,0 @@
import clsx from 'clsx';
import React, { Fragment, useState, useEffect, useMemo, useCallback } from 'react';
import { md5 } from 'js-md5';
import { type as osType } from '@tauri-apps/plugin-os';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import { KOSyncClient } from '@/services/sync/KOSyncClient';
import { KOSyncChecksumMethod, KOSyncStrategy } from '@/types/settings';
import { debounce } from '@/utils/debounce';
import { getOSPlatform } from '@/utils/misc';
import Dialog from '@/components/Dialog';
type Option = {
value: string;
label: string;
disabled?: boolean;
};
type SelectProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: Option[];
disabled?: boolean;
className?: string;
};
const StyledSelect: React.FC<SelectProps> = ({
value,
onChange,
options,
className,
disabled = false,
}) => {
return (
<select
value={value}
onChange={onChange}
className={clsx(
'select select-bordered h-12 w-full text-sm focus:outline-none focus:ring-0',
className,
)}
disabled={disabled}
>
{options.map(({ value, label, disabled = false }) => (
<option key={value} value={value} disabled={disabled}>
{label}
</option>
))}
</select>
);
};
export const setKOSyncSettingsWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('kosync_settings_window');
if (dialog) {
const event = new CustomEvent('setKOSyncSettingsVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const KOSyncSettingsWindow: React.FC = () => {
const _ = useTranslation();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { envConfig, appService } = useEnv();
const [isOpen, setIsOpen] = useState(false);
const [url, setUrl] = useState(settings.kosync.serverUrl || '');
const [username, setUsername] = useState(settings.kosync.username || '');
const [password, setPassword] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('');
const [deviceName, setDeviceName] = useState('');
const [osName, setOsName] = useState('');
// Get the OS name once
useEffect(() => {
const formatOsName = (name: string): string => {
if (!name) return '';
if (name.toLowerCase() === 'macos') return 'macOS';
if (name.toLowerCase() === 'ios') return 'iOS';
return name.charAt(0).toUpperCase() + name.slice(1);
};
const getOsName = async () => {
let name = '';
if (appService?.appPlatform === 'tauri') {
name = await osType();
} else {
const platform = getOSPlatform();
if (platform !== 'unknown') {
name = platform;
}
}
setOsName(formatOsName(name));
};
getOsName();
}, [appService]);
useEffect(() => {
const defaultName = osName ? `Readest (${osName})` : 'Readest';
setDeviceName(settings.kosync.deviceName || defaultName);
}, [settings.kosync.deviceName, osName]);
const isConfigured = useMemo(() => !!settings.kosync.userkey, [settings.kosync.userkey]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSaveDeviceName = useCallback(
debounce((newDeviceName: string) => {
const newSettings = { ...settings, koreaderSyncDeviceName: newDeviceName };
setSettings(newSettings);
saveSettings(envConfig, newSettings);
}, 500),
[settings, setSettings, saveSettings, envConfig],
);
const handleDeviceNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value;
setDeviceName(newName);
debouncedSaveDeviceName(newName);
};
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
setUrl(settings.kosync.serverUrl || '');
setUsername(settings.kosync.username || '');
setPassword('');
setConnectionStatus('');
}
};
const el = document.getElementById('kosync_settings_window');
el?.addEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
};
}, [settings.kosync.serverUrl, settings.kosync.username]);
const handleConnect = async () => {
setIsConnecting(true);
const config = {
...settings.kosync,
serverUrl: url,
username,
userkey: md5(password),
password,
deviceName,
enabled: true,
};
const client = new KOSyncClient(config);
const result = await client.connect(username, password);
if (result.success) {
const newSettings = {
...settings,
kosync: config,
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
} else {
setConnectionStatus('');
eventDispatcher.dispatch('toast', {
message: `${_('Failed to connect')}: ${_(result.message || 'Connection error')}`,
type: 'error',
});
}
setIsConnecting(false);
setPassword('');
};
const handleDisconnect = async () => {
const kosync = {
...settings.kosync,
userkey: '',
enabled: false,
};
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
setUsername('');
eventDispatcher.dispatch('toast', { message: _('Disconnected'), type: 'info' });
};
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const kosync = {
...settings.kosync,
strategy: e.target.value as KOSyncStrategy,
};
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const handleChecksumMethodChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const kosync = {
...settings.kosync,
checksumMethod: e.target.value as KOSyncChecksumMethod,
};
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
return (
<Dialog
id='kosync_settings_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('KOReader Sync Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<Fragment key='configured'>
<div className='text-center'>
<p className='text-base-content/80 text-sm'>
{_('Sync as {{userDisplayName}}', {
userDisplayName: settings.kosync.username,
})}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>{_('Sync Server Connected')}</span>
<input
type='checkbox'
className='toggle'
checked={settings.kosync.enabled}
onChange={() => handleDisconnect()}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Sync Strategy')}</span>
</label>
<StyledSelect
value={settings.kosync.strategy}
onChange={handleStrategyChange}
options={[
{ value: 'prompt', label: _('Ask on conflict') },
{ value: 'silent', label: _('Always use latest') },
{ value: 'send', label: _('Send changes only') },
{ value: 'receive', label: _('Receive changes only') },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Checksum Method')}</span>
</label>
<StyledSelect
value={settings.kosync.checksumMethod}
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
{ value: 'filename', label: _('File Name'), disabled: true },
]}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Device Name')}</span>
</label>
<input
type='text'
placeholder={osName ? `Readest (${osName})` : 'Readest'}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={deviceName}
onChange={handleDeviceNameChange}
/>
</div>
</Fragment>
) : (
<Fragment key='login'>
<p className='text-base-content/70 text-center text-sm'>
{_('Connect to your KOReader Sync server.')}
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Server URL')}</span>
</label>
<input
type='text'
placeholder='https://koreader.sync.server'
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<form className='flex flex-col gap-4'>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Username')}</span>
</label>
<input
type='text'
placeholder={_('Your Username')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete='username'
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Password')}</span>
</label>
<input
type='password'
placeholder={_('Your Password')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete='current-password'
/>
</div>
</form>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
disabled={isConnecting || !url || !username || !password}
>
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
</button>
{connectionStatus && (
<div className='text-error h-4 text-center text-sm'>{connectionStatus}</div>
)}
</Fragment>
)}
</div>
)}
</Dialog>
);
};
@@ -25,9 +25,6 @@ import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { KeyboardShortcutsHelp } from '@/components/KeyboardShortcutsHelp';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { KOSyncSettingsWindow } from './KOSyncSettings';
import { ReadwiseSettingsWindow } from './ReadwiseSettings';
import { HardcoverSettingsWindow } from './HardcoverSettings';
import { ProofreadRulesManager } from './ProofreadRules';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
@@ -183,9 +180,6 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<AboutWindow />
<KeyboardShortcutsHelp />
<UpdaterWindow />
<KOSyncSettingsWindow />
<ReadwiseSettingsWindow />
<HardcoverSettingsWindow />
<ProofreadRulesManager />
<Toast />
</Suspense>
@@ -1,172 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import { ReadwiseClient } from '@/services/readwise';
import Dialog from '@/components/Dialog';
export const setReadwiseSettingsWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('readwise_settings_window');
if (dialog) {
const event = new CustomEvent('setReadwiseSettingsVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const ReadwiseSettingsWindow: React.FC = () => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [isOpen, setIsOpen] = useState(false);
const [accessToken, setAccessToken] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const isConfigured = !!settings.readwise?.accessToken;
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
setAccessToken('');
}
};
const el = document.getElementById('readwise_settings_window');
el?.addEventListener('setReadwiseSettingsVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setReadwiseSettingsVisibility', handleCustomEvent as EventListener);
};
}, []);
const handleConnect = async () => {
setIsConnecting(true);
try {
const client = new ReadwiseClient({ enabled: true, accessToken, lastSyncedAt: 0 });
const { valid, isNetworkError } = await client.validateToken();
if (valid) {
const newSettings = {
...settings,
readwise: {
enabled: true,
accessToken,
lastSyncedAt: settings.readwise?.lastSyncedAt ?? 0,
},
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
} else if (isNetworkError) {
eventDispatcher.dispatch('toast', {
message: _('Unable to connect to Readwise. Please check your network connection.'),
type: 'error',
});
} else {
eventDispatcher.dispatch('toast', {
message: _('Invalid Readwise access token'),
type: 'error',
});
}
} finally {
setIsConnecting(false);
setAccessToken('');
}
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
readwise: { enabled: false, accessToken: '', lastSyncedAt: 0 },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
eventDispatcher.dispatch('toast', { message: _('Disconnected from Readwise'), type: 'info' });
};
const handleToggleEnabled = async () => {
const newSettings = {
...settings,
readwise: { ...settings.readwise, enabled: !settings.readwise?.enabled },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const lastSyncedAt = settings.readwise?.lastSyncedAt ?? 0;
const lastSyncedLabel = lastSyncedAt ? new Date(lastSyncedAt).toLocaleString() : _('Never');
return (
<Dialog
id='readwise_settings_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Readwise Settings')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
{isConfigured ? (
<>
<div className='text-center'>
<p className='text-base-content/80 text-sm'>{_('Connected to Readwise')}</p>
<p className='text-base-content/60 mt-1 text-xs'>
{_('Last synced: {{time}}', { time: lastSyncedLabel })}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>{_('Sync Enabled')}</span>
<input
type='checkbox'
className='toggle'
checked={settings.readwise?.enabled ?? false}
onChange={handleToggleEnabled}
/>
</div>
<button className='btn btn-outline btn-sm mt-2' onClick={handleDisconnect}>
{_('Disconnect')}
</button>
</>
) : (
<>
<p className='text-base-content/70 text-center text-sm'>
{_('Connect your Readwise account to sync highlights.')}
</p>
<p className='text-base-content/60 text-center text-xs'>
{_('Get your access token at')}{' '}
<a
href='https://readwise.io/access_token'
target='_blank'
rel='noopener noreferrer'
className='link link-primary'
>
readwise.io/access_token
</a>
</p>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Access Token')}</span>
</label>
<input
type='password'
placeholder={_('Paste your Readwise access token')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
/>
</div>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
disabled={isConnecting || !accessToken}
>
{isConnecting ? <span className='loading loading-spinner'></span> : _('Connect')}
</button>
</>
)}
</div>
)}
</Dialog>
);
};
@@ -48,8 +48,9 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
>
{/* `overflow-hidden rounded-lg` clips the body's section backgrounds /
borders to the Popup's rounded shape. */}
<div className='flex h-full flex-col overflow-hidden rounded-lg pt-2'>
<div className='flex h-full flex-col overflow-hidden rounded-lg pt-4'>
<DictionaryResultsHeader
headerClassName='-mt-2'
currentWord={state.currentWord}
canGoBack={state.canGoBack}
goBack={state.goBack}
@@ -70,6 +70,7 @@ export function useDictionaryResults({
const { appService } = useEnv();
const { dictionaries, settings } = useCustomDictionaryStore();
const isDarkMode = useThemeStore((s) => s.isDarkMode);
const themeCode = useThemeStore((s) => s.themeCode);
const computedProviders = getEnabledProviders({
settings,
@@ -215,6 +216,8 @@ export function useDictionaryResults({
container,
onNavigate: pushWord,
isDarkMode,
bg: themeCode.bg,
fg: themeCode.fg,
});
}
} catch (err) {
@@ -242,7 +245,7 @@ export function useDictionaryResults({
});
return () => controllers.forEach((c) => c.abort());
}, [currentWord, definitionProviders, lang, pushWord, isDarkMode]);
}, [currentWord, definitionProviders, lang, pushWord, isDarkMode, themeCode.bg, themeCode.fg]);
// Visible cards = providers that are still loading or finished with a
// result. Empty/unsupported/error cards are removed entirely.
@@ -299,6 +302,7 @@ export function useDictionaryResults({
}
interface DictionaryResultsHeaderProps {
headerClassName?: string;
currentWord: string;
canGoBack: boolean;
goBack: () => void;
@@ -306,6 +310,7 @@ interface DictionaryResultsHeaderProps {
}
export const DictionaryResultsHeader: React.FC<DictionaryResultsHeaderProps> = ({
headerClassName,
currentWord,
canGoBack,
goBack,
@@ -313,7 +318,7 @@ export const DictionaryResultsHeader: React.FC<DictionaryResultsHeaderProps> = (
}) => {
const _ = useTranslation();
return (
<div className='-mt-3 flex h-8 w-full items-center justify-between px-2'>
<div className={clsx('flex h-8 w-full items-center justify-between px-2', headerClassName)}>
<div className='flex h-8 w-8 items-center justify-center'>
{canGoBack ? (
<button
@@ -25,13 +25,14 @@ const DictionarySheet: React.FC<DictionarySheetProps> = ({ word, lang, onDismiss
dismissible
header={
<DictionaryResultsHeader
headerClassName='-mt-4'
currentWord={state.currentWord}
canGoBack={state.canGoBack}
goBack={state.goBack}
onManage={onManage}
/>
}
contentClassName='!px-0'
contentClassName='!px-0 !mt-0'
onClose={onDismiss}
>
<DictionaryResultsBody {...state} />
@@ -10,7 +10,6 @@ import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
@@ -18,9 +17,6 @@ import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { navigateToLogin } from '@/utils/nav';
import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
import { setReadwiseSettingsWindowVisible } from '@/app/reader/components/ReadwiseSettings';
import { setHardcoverSettingsWindowVisible } from '@/app/reader/components/HardcoverSettings';
import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import useBooksManager from '../../hooks/useBooksManager';
@@ -38,7 +34,6 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
const { envConfig, appService } = useEnv();
const { user } = useAuth();
const { settings } = useSettingsStore();
const { getConfig, setConfig, saveConfig } = useBookDataStore();
const { bookKeys, recreateViewer, getViewSettings } = useReaderStore();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
@@ -47,9 +42,6 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
const viewSettings = getViewSettings(sideBarBookKey!);
const [isSortedTOC, setIsSortedTOC] = React.useState(viewSettings?.sortedTOC || false);
const hardcoverSyncEnabledForBook = !!(
sideBarBookKey && getConfig(sideBarBookKey)?.hardcoverSyncEnabled
);
const handleParallelView = (id: string) => {
openParallelView(id);
@@ -90,10 +82,6 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
unsetParallel(bookKeys);
setIsDropdownOpen?.(false);
};
const showKoSyncSettingsWindow = () => {
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const showProofreadRulesWindow = () => {
setProofreadRulesVisibility(true);
setIsDropdownOpen?.(false);
@@ -106,18 +94,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
eventDispatcher.dispatch('push-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const showReadwiseSettingsWindow = () => {
setReadwiseSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handlePushReadwise = () => {
eventDispatcher.dispatch('readwise-push-all', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const showHardcoverSettingsWindow = () => {
setHardcoverSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handlePushHardcoverNotes = () => {
eventDispatcher.dispatch('hardcover-push-notes', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
@@ -126,30 +106,6 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
eventDispatcher.dispatch('hardcover-push-progress', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const handleToggleHardcoverBookSync = async () => {
if (!sideBarBookKey) return;
const config = getConfig(sideBarBookKey);
if (!config) return;
const nextValue = !config.hardcoverSyncEnabled;
const updatedConfig = {
...config,
hardcoverSyncEnabled: nextValue,
updatedAt: Date.now(),
};
setConfig(sideBarBookKey, {
hardcoverSyncEnabled: nextValue,
updatedAt: updatedConfig.updatedAt,
});
await saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
eventDispatcher.dispatch('toast', {
message: nextValue
? _('Hardcover sync enabled for this book')
: _('Hardcover sync disabled for this book'),
type: 'info',
});
setIsDropdownOpen?.(false);
};
const toggleDiscordPresence = () => {
const discordRichPresenceEnabled = !settings.discordRichPresenceEnabled;
saveSysSettings(envConfig, 'discordRichPresenceEnabled', discordRichPresenceEnabled);
@@ -203,44 +159,31 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
) : (
<MenuItem label={_('Enter Parallel Read')} onClick={handleSetParallel} />
))}
<hr aria-hidden='true' className='border-base-200 my-1' />
{settings.kosync.enabled ? (
{(settings.kosync.enabled || settings.readwise.enabled || settings.hardcover.enabled) && (
<hr aria-hidden='true' className='border-base-200 my-1' />
)}
{settings.kosync.enabled && (
<MenuItem label={_('KOReader Sync')} detailsOpen={false} buttonClass='py-2'>
<ul className='flex flex-col ps-1'>
<MenuItem label={_('Config')} noIcon onClick={showKoSyncSettingsWindow} />
<MenuItem label={_('Push Progress')} noIcon onClick={handlePushKOSync} />
<MenuItem label={_('Pull Progress')} noIcon onClick={handlePullKOSync} />
</ul>
</MenuItem>
) : (
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
)}
{settings.readwise.enabled ? (
{settings.readwise.enabled && (
<MenuItem label={_('Readwise Sync')} detailsOpen={false} buttonClass='py-2'>
<ul className='flex flex-col ps-1'>
<MenuItem label={_('Config')} noIcon onClick={showReadwiseSettingsWindow} />
<MenuItem label={_('Push Highlights')} noIcon onClick={handlePushReadwise} />
</ul>
</MenuItem>
) : (
<MenuItem label={_('Readwise Sync')} onClick={showReadwiseSettingsWindow} />
)}
{settings.hardcover.enabled ? (
{settings.hardcover.enabled && (
<MenuItem label={_('Hardcover Sync')} detailsOpen={false} buttonClass='py-2'>
<ul className='flex flex-col ps-1'>
<MenuItem label={_('Config')} noIcon onClick={showHardcoverSettingsWindow} />
<MenuItem
label={_('Enable for This Book')}
noIcon
Icon={hardcoverSyncEnabledForBook ? MdCheck : undefined}
onClick={handleToggleHardcoverBookSync}
/>
<MenuItem label={_('Push Progress')} noIcon onClick={handlePushHardcoverProgress} />
<MenuItem label={_('Push Notes')} noIcon onClick={handlePushHardcoverNotes} />
</ul>
</MenuItem>
) : (
<MenuItem label={_('Hardcover Sync')} onClick={showHardcoverSettingsWindow} />
)}
{appService?.isDesktopApp && (
<>
@@ -39,13 +39,6 @@ export const useHardcoverSync = (bookKey: string) => {
const config = getConfig(bookKey);
const book = getBookData(bookKey)?.book;
if (!config || !book) return;
if (!config.hardcoverSyncEnabled) {
eventDispatcher.dispatch('toast', {
message: _('Enable Hardcover sync for this book first.'),
type: 'info',
});
return;
}
const eligibleNotes = (config.booknotes ?? []).filter(
(note: BookNote) =>
@@ -99,13 +92,6 @@ export const useHardcoverSync = (bookKey: string) => {
const config = getConfig(bookKey);
const book = getBookData(bookKey)?.book;
if (!config || !book) return;
if (!config.hardcoverSyncEnabled) {
eventDispatcher.dispatch('toast', {
message: _('Enable Hardcover sync for this book first.'),
type: 'info',
});
return;
}
const client = await getClient();
if (!client) {
+37 -8
View File
@@ -1,5 +1,7 @@
import clsx from 'clsx';
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
@@ -27,6 +29,15 @@ interface DialogProps {
bgClassName?: string;
boxClassName?: string;
contentClassName?: string;
/**
* Replace the body's native `overflow-y-auto` with OverlayScrollbars so
* the scrollbar is the floating, theme-aware kind instead of the host's
* native one (which Android/iOS webviews auto-hide entirely, leaving the
* user with no visible scrollbar). Opt-in per dialog set true for
* long-content dialogs like Settings; leave false for short modals where
* native scrolling is fine.
*/
useOverlayScroll?: boolean;
onClose: () => void;
}
@@ -42,6 +53,7 @@ const Dialog: React.FC<DialogProps> = ({
bgClassName,
boxClassName,
contentClassName,
useOverlayScroll = false,
onClose,
}) => {
const _ = useTranslation();
@@ -280,14 +292,31 @@ const Dialog: React.FC<DialogProps> = ({
)}
</div>
<div
className={clsx(
'text-base-content my-2 flex-grow overflow-y-auto px-6 sm:px-[10%]',
contentClassName,
)}
>
{children}
</div>
{useOverlayScroll ? (
// OverlayScrollbarsComponent owns the scroller; the inner viewport
// gets `overflow-y-auto` automatically. Keep the same flex /
// padding chassis so the body still occupies remaining height
// and the children's horizontal rhythm is unchanged.
<OverlayScrollbarsComponent
className={clsx('text-base-content my-2 flex-grow px-6 sm:px-[10%]', contentClassName)}
options={{
scrollbars: { autoHide: 'scroll', clickScroll: true },
showNativeOverlaidScrollbars: false,
}}
defer
>
{children}
</OverlayScrollbarsComponent>
) : (
<div
className={clsx(
'text-base-content my-2 flex-grow overflow-y-auto px-6 sm:px-[10%]',
contentClassName,
)}
>
{children}
</div>
)}
</div>
</dialog>
);
@@ -8,6 +8,7 @@ import { useEnv } from '@/context/EnvContext';
import { getAIProvider } from '@/services/ai/providers';
import { DEFAULT_AI_SETTINGS, GATEWAY_MODELS, MODEL_PRICING } from '@/services/ai/constants';
import type { AISettings, AIProviderName } from '@/services/ai/types';
import { BoxedList, SettingLabel, SettingsRow, SettingsSwitchRow } from './primitives';
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
type CustomModelStatus = 'idle' | 'validating' | 'valid' | 'invalid';
@@ -311,245 +312,220 @@ const AIPanel: React.FC = () => {
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('AI Assistant')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span>{_('Enable AI Assistant')}</span>
<input
type='checkbox'
className='toggle'
checked={enabled}
onChange={() => setEnabled(!enabled)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('AI Assistant')}>
<SettingsSwitchRow
label={_('Enable AI Assistant')}
checked={enabled}
onChange={() => setEnabled(!enabled)}
/>
</BoxedList>
<div className={clsx('w-full', disabledSection)}>
<h2 className='mb-2 font-medium'>{_('Provider')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span>{_('Ollama (Local)')}</span>
<input
type='radio'
name='ai-provider'
className='radio'
checked={provider === 'ollama'}
onChange={() => setProvider('ollama')}
disabled={!enabled}
/>
</div>
<div className='config-item'>
<span>{_('AI Gateway (Cloud)')}</span>
<input
type='radio'
name='ai-provider'
className='radio'
checked={provider === 'ai-gateway'}
onChange={() => setProvider('ai-gateway')}
disabled={!enabled}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Provider')} className={disabledSection}>
<SettingsRow label={_('Ollama (Local)')} asLabel>
<input
type='radio'
name='ai-provider'
className='radio'
checked={provider === 'ollama'}
onChange={() => setProvider('ollama')}
disabled={!enabled}
/>
</SettingsRow>
<SettingsRow label={_('AI Gateway (Cloud)')} asLabel>
<input
type='radio'
name='ai-provider'
className='radio'
checked={provider === 'ai-gateway'}
onChange={() => setProvider('ai-gateway')}
disabled={!enabled}
/>
</SettingsRow>
</BoxedList>
{provider === 'ollama' && (
<div className={clsx('w-full', disabledSection)}>
<h2 className='mb-2 font-medium'>{_('Ollama Configuration')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<div className='flex w-full items-center justify-between'>
<span>{_('Server URL')}</span>
<button
className='btn btn-ghost btn-xs'
onClick={fetchOllamaModels}
disabled={!enabled || fetchingModels}
title={_('Refresh Models')}
>
<PiArrowsClockwise className='size-4' />
</button>
</div>
<input
type='text'
className='input input-bordered input-sm w-full'
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder='http://127.0.0.1:11434'
disabled={!enabled}
/>
</div>
{ollamaModels.length > 0 ? (
<>
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<span>{_('AI Model')}</span>
<select
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
value={ollamaModel}
onChange={(e) => setOllamaModel(e.target.value)}
disabled={!enabled}
>
{ollamaModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<span>{_('Embedding Model')}</span>
<select
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
value={ollamaEmbeddingModel}
onChange={(e) => setOllamaEmbeddingModel(e.target.value)}
disabled={!enabled}
>
{ollamaModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
</>
) : !fetchingModels ? (
<div className='config-item'>
<span className='text-warning text-sm'>{_('No models detected')}</span>
</div>
) : null}
<BoxedList title={_('Ollama Configuration')} className={disabledSection}>
{/* Stacked-content rows: label-on-top, input below used when the
control is too wide to fit alongside the label (full-width text
inputs, long selects). Custom <div> rather than <SettingsRow>
since SettingsRow assumes label-left/control-right. */}
<div className='flex flex-col gap-2 py-3 pe-4'>
<div className='flex w-full items-center justify-between'>
<SettingLabel>{_('Server URL')}</SettingLabel>
<button
className='hover:bg-base-200 inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors duration-150'
onClick={fetchOllamaModels}
disabled={!enabled || fetchingModels}
title={_('Refresh Models')}
aria-label={_('Refresh Models')}
>
<PiArrowsClockwise className='size-4' />
</button>
</div>
<input
type='text'
className='input input-bordered input-sm w-full'
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder='http://127.0.0.1:11434'
disabled={!enabled}
/>
</div>
</div>
{ollamaModels.length > 0 ? (
<>
<div className='flex flex-col gap-2 py-3 pe-4'>
<SettingLabel>{_('AI Model')}</SettingLabel>
<select
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
value={ollamaModel}
onChange={(e) => setOllamaModel(e.target.value)}
disabled={!enabled}
>
{ollamaModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
<div className='flex flex-col gap-2 py-3 pe-4'>
<SettingLabel>{_('Embedding Model')}</SettingLabel>
<select
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
value={ollamaEmbeddingModel}
onChange={(e) => setOllamaEmbeddingModel(e.target.value)}
disabled={!enabled}
>
{ollamaModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
</>
) : !fetchingModels ? (
<SettingsRow
label={<span className='text-warning text-sm'>{_('No models detected')}</span>}
/>
) : null}
</BoxedList>
)}
{provider === 'ai-gateway' && (
<div className={clsx('w-full', disabledSection)}>
<h2 className='mb-2 font-medium'>{_('AI Gateway Configuration')}</h2>
<p className='text-base-content/70 mb-3 text-sm'>
{_(
'Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting "Custom Model" below.',
)}
</p>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<div className='flex w-full items-center justify-between'>
<span>{_('API Key')}</span>
<a
href='https://vercel.com/docs/ai/ai-gateway'
target='_blank'
rel='noopener noreferrer'
className={clsx('link text-xs', !enabled && 'pointer-events-none')}
>
{_('Get Key')}
</a>
</div>
<BoxedList
title={_('AI Gateway Configuration')}
description={_(
'Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting "Custom Model" below.',
)}
className={disabledSection}
>
<div className='flex flex-col gap-2 px-4 py-3'>
<div className='flex w-full items-center justify-between'>
<SettingLabel>{_('API Key')}</SettingLabel>
<a
href='https://vercel.com/docs/ai/ai-gateway'
target='_blank'
rel='noopener noreferrer'
className={clsx('link text-xs', !enabled && 'pointer-events-none')}
>
{_('Get Key')}
</a>
</div>
<input
type='password'
className='input input-bordered input-sm w-full'
value={gatewayKey}
onChange={(e) => setGatewayKey(e.target.value)}
placeholder='vck_...'
disabled={!enabled}
/>
</div>
<div className='flex flex-col gap-2 px-4 py-3'>
<SettingLabel>{_('Model')}</SettingLabel>
<select
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
value={selectedModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={!enabled}
>
{modelOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label} ${opt.inputCost}/M in, ${opt.outputCost}/M out
</option>
))}
<option value={CUSTOM_MODEL_VALUE}>{_('Custom Model...')}</option>
</select>
</div>
{selectedModel === CUSTOM_MODEL_VALUE && (
<div className='flex flex-col gap-2 px-4 py-3'>
<SettingLabel>{_('Custom Model ID')}</SettingLabel>
<div className='flex w-full gap-2'>
<input
type='password'
className='input input-bordered input-sm w-full'
value={gatewayKey}
onChange={(e) => setGatewayKey(e.target.value)}
placeholder='vck_...'
type='text'
className='input input-bordered input-sm flex-1'
value={customModelInput}
onChange={(e) => {
setCustomModelInput(e.target.value);
setCustomModelStatus('idle');
setCustomModelError('');
}}
placeholder='provider/model-name'
disabled={!enabled}
/>
</div>
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<span>{_('Model')}</span>
<select
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
value={selectedModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={!enabled}
<button
className='btn btn-outline btn-sm'
onClick={validateCustomModel}
disabled={!enabled || customModelStatus === 'validating'}
>
{modelOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label} ${opt.inputCost}/M in, ${opt.outputCost}/M out
</option>
))}
<option value={CUSTOM_MODEL_VALUE}>{_('Custom Model...')}</option>
</select>
{customModelStatus === 'validating' ? (
<PiSpinner className='size-4 animate-spin' />
) : (
_('Validate')
)}
</button>
</div>
{selectedModel === CUSTOM_MODEL_VALUE && (
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<span>{_('Custom Model ID')}</span>
<div className='flex w-full gap-2'>
<input
type='text'
className='input input-bordered input-sm flex-1'
value={customModelInput}
onChange={(e) => {
setCustomModelInput(e.target.value);
setCustomModelStatus('idle');
setCustomModelError('');
}}
placeholder='provider/model-name'
disabled={!enabled}
/>
<button
className='btn btn-outline btn-sm'
onClick={validateCustomModel}
disabled={!enabled || customModelStatus === 'validating'}
>
{customModelStatus === 'validating' ? (
<PiSpinner className='size-4 animate-spin' />
) : (
_('Validate')
)}
</button>
</div>
{customModelStatus === 'valid' && customModelPricing && (
<span className='text-success flex items-center gap-1 text-sm'>
<PiCheckCircle />
{_('Model available')} ${customModelPricing.input}/M in, $
{customModelPricing.output}/M out
</span>
)}
{customModelStatus === 'invalid' && (
<span className='text-error text-sm'>{customModelError}</span>
)}
</div>
{customModelStatus === 'valid' && customModelPricing && (
<span className='text-success flex items-center gap-1 text-sm'>
<PiCheckCircle />
{_('Model available')} ${customModelPricing.input}/M in, $
{customModelPricing.output}/M out
</span>
)}
{customModelStatus === 'invalid' && (
<span className='text-error text-sm'>{customModelError}</span>
)}
</div>
</div>
</div>
)}
</BoxedList>
)}
<div className={clsx('w-full', disabledSection)}>
<h2 className='mb-2 font-medium'>{_('Connection')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<button
className='btn btn-outline btn-sm'
onClick={handleTestConnection}
disabled={!enabled || connectionStatus === 'testing'}
>
{_('Test Connection')}
</button>
<div>
{connectionStatus === 'success' && (
<span className='text-success flex items-center gap-1 text-sm'>
<PiCheckCircle className='size-4 shrink-0' />
{_('Connected')}
</span>
)}
{connectionStatus === 'error' && (
<span className='text-error flex items-center gap-1 text-sm'>
<PiWarningCircle className='size-4 shrink-0' />
{errorMessage || _('Failed')}
</span>
)}
</div>
</div>
<BoxedList title={_('Connection')} className={disabledSection}>
<div className='flex min-h-14 items-center justify-between gap-3 pe-4'>
<button
className='btn btn-outline btn-sm'
onClick={handleTestConnection}
disabled={!enabled || connectionStatus === 'testing'}
>
{_('Test Connection')}
</button>
<div>
{connectionStatus === 'success' && (
<span className='text-success flex items-center gap-1 text-sm'>
<PiCheckCircle className='size-4 shrink-0' />
{_('Connected')}
</span>
)}
{connectionStatus === 'error' && (
<span className='text-error flex items-center gap-1 text-sm'>
<PiWarningCircle className='size-4 shrink-0' />
{errorMessage || _('Failed')}
</span>
)}
</div>
</div>
</div>
</BoxedList>
</div>
);
};
@@ -192,10 +192,10 @@ export default function AppLockDialog() {
return (
<ModalPortal>
<dialog className='modal modal-open'>
<div className='modal-box bg-base-200 max-w-md rounded-2xl p-6 shadow-2xl'>
<div className='modal-box bg-base-100 max-w-md rounded-2xl p-6 shadow-2xl'>
<h3 className='mb-1.5 text-lg font-semibold tracking-tight'>{title}</h3>
<p className='text-base-content/60 mb-5 text-sm leading-relaxed'>{description}</p>
<form onSubmit={handleSubmit} className='flex flex-col gap-4'>
<p className='text-base-content/70 mb-6 text-sm leading-relaxed'>{description}</p>
<form onSubmit={handleSubmit} className='flex flex-col gap-5'>
{(mode === 'change' || mode === 'disable') && (
<div className='flex flex-col items-center gap-2'>
<span className={fieldLabelClass}>{_('Current PIN')}</span>
@@ -246,12 +246,20 @@ export default function AppLockDialog() {
>
{error || ' '}
</p>
<div className='flex justify-end gap-2'>
<div className='flex justify-end gap-3'>
<button
type='button'
onClick={closeDialog}
disabled={busy}
className='eink-bordered hover:bg-base-300/70 rounded-lg px-4 py-2 text-sm font-medium transition-colors'
className={clsx(
'eink-bordered',
'h-10 rounded-lg px-4 text-sm font-medium',
'text-base-content hover:bg-base-200',
'transition-colors duration-150',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
'disabled:cursor-not-allowed disabled:opacity-60',
'disabled:hover:bg-transparent',
)}
>
{_('Cancel')}
</button>
@@ -259,7 +267,9 @@ export default function AppLockDialog() {
type='submit'
disabled={busy}
className={clsx(
'btn btn-primary text-primary-content hover:bg-primary/90 active:bg-primary/80 rounded-lg border-0 px-4 py-2 text-sm font-medium transition-colors',
'btn btn-primary',
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
busy && 'opacity-60',
)}
>
@@ -22,6 +22,8 @@ import { useFileSelector } from '@/hooks/useFileSelector';
import { PREDEFINED_TEXTURES } from '@/styles/textures';
import { useAtmosphereStore } from '@/store/atmosphereStore';
import { DefaultHighlightColor, HighlightColor, UserHighlightColor } from '@/types/book';
import clsx from 'clsx';
import { SettingLabel } from './primitives';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import ThemeEditor from './color/ThemeEditor';
import ThemeModeSelector from './color/ThemeModeSelector';
@@ -318,11 +320,15 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
data-setting-id='settings.color.themeMode'
/>
<div
<label
data-setting-id='settings.color.invertImageInDarkMode'
className='flex items-center justify-between'
className={clsx(
'flex items-center justify-between px-4',
!isDarkMode && 'cursor-not-allowed opacity-50',
isDarkMode && 'cursor-pointer',
)}
>
<h2 className='font-medium'>{_('Invert Image In Dark Mode')}</h2>
<SettingLabel>{_('Invert Image In Dark Mode')}</SettingLabel>
<input
type='checkbox'
className='toggle'
@@ -330,20 +336,20 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
disabled={!isDarkMode}
onChange={() => setInvertImgColorInDark(!invertImgColorInDark)}
/>
</div>
</label>
<div
<label
data-setting-id='settings.color.overrideBookColor'
className='flex items-center justify-between'
className='flex cursor-pointer items-center justify-between px-4'
>
<h2 className='font-medium'>{_('Override Book Color')}</h2>
<SettingLabel>{_('Override Book Color')}</SettingLabel>
<input
type='checkbox'
className='toggle'
checked={overrideColor}
onChange={() => setOverrideColor(!overrideColor)}
/>
</div>
</label>
<ThemeColorSelector
themes={themes.concat(customThemes)}
@@ -12,8 +12,8 @@ import { getMaxInlineSize } from '@/utils/config';
import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { annotationToolQuickActions } from '@/app/reader/components/annotator/AnnotationTools';
import { BoxedList, SettingsRow, SettingsSelect, SettingsSwitchRow } from './primitives';
import NumberInput from './NumberInput';
import Select from '../Select';
const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
@@ -241,254 +241,156 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full' data-setting-id='settings.control.scrolledMode'>
<h2 className='mb-2 font-medium'>{_('Scroll')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Scrolled Mode')}</span>
<input
type='checkbox'
className='toggle'
checked={isScrolledMode}
disabled={bookData?.isFixedLayout}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
<div
className='config-item'
data-setting-id='settings.control.scroll.noContinuousScroll'
>
<span className=''>{_('Single Section Scroll')}</span>
<input
type='checkbox'
className='toggle'
checked={noContinuousScroll}
disabled={!viewSettings.scrolled}
onChange={() => setNoContinuousScroll(!noContinuousScroll)}
/>
</div>
<NumberInput
label={_('Overlap Pixels')}
value={scrollingOverlap}
onChange={setScrollingOverlap}
disabled={!viewSettings.scrolled}
min={0}
max={200}
step={10}
data-setting-id='settings.control.overlapPixels'
/>
<div className='config-item' data-setting-id='settings.control.scroll.hideScrollbar'>
<span className=''>{_('Hide Scrollbar')}</span>
<input
type='checkbox'
className='toggle'
checked={hideScrollbar}
disabled={!viewSettings.scrolled}
onChange={() => setHideScrollbar(!hideScrollbar)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Scroll')} data-setting-id='settings.control.scrolledMode'>
<SettingsSwitchRow
label={_('Scrolled Mode')}
checked={isScrolledMode}
disabled={bookData?.isFixedLayout}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
<SettingsSwitchRow
label={_('Single Section Scroll')}
checked={noContinuousScroll}
disabled={!viewSettings.scrolled}
onChange={() => setNoContinuousScroll(!noContinuousScroll)}
data-setting-id='settings.control.scroll.noContinuousScroll'
/>
<NumberInput
label={_('Overlap Pixels')}
value={scrollingOverlap}
onChange={setScrollingOverlap}
disabled={!viewSettings.scrolled}
min={0}
max={200}
step={10}
data-setting-id='settings.control.overlapPixels'
/>
<SettingsSwitchRow
label={_('Hide Scrollbar')}
checked={hideScrollbar}
disabled={!viewSettings.scrolled}
onChange={() => setHideScrollbar(!hideScrollbar)}
data-setting-id='settings.control.scroll.hideScrollbar'
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.control.clickToPaginate'>
<h2 className='mb-2 font-medium'>{_('Pagination')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span className=''>
{appService?.isMobileApp ? _('Tap to Paginate') : _('Click to Paginate')}
</span>
<input
type='checkbox'
className='toggle'
checked={!isDisableClick}
onChange={() => setIsDisableClick(!isDisableClick)}
/>
</div>
<div className='config-item' data-setting-id='settings.control.clickBothSides'>
<span className=''>
{appService?.isMobileApp ? _('Tap Both Sides') : _('Click Both Sides')}
</span>
<input
type='checkbox'
className='toggle'
checked={fullscreenClickArea}
disabled={isDisableClick}
onChange={() => setFullscreenClickArea(!fullscreenClickArea)}
/>
</div>
<div className='config-item' data-setting-id='settings.control.swapClickSides'>
<span className=''>
{appService?.isMobileApp ? _('Swap Tap Sides') : _('Swap Click Sides')}
</span>
<input
type='checkbox'
className='toggle'
checked={swapClickArea}
disabled={isDisableClick || fullscreenClickArea}
onChange={() => setSwapClickArea(!swapClickArea)}
/>
</div>
<div className='config-item' data-setting-id='settings.control.disableDoubleClick'>
<span className=''>
{appService?.isMobileApp ? _('Disable Double Tap') : _('Disable Double Click')}
</span>
<input
type='checkbox'
className='toggle'
checked={isDisableDoubleClick}
onChange={() => setIsDisableDoubleClick(!isDisableDoubleClick)}
/>
</div>
{appService?.isMobileApp && (
<div className='config-item'>
<span className=''>{_('Volume Keys for Page Flip')}</span>
<input
type='checkbox'
className='toggle'
checked={volumeKeysToFlip}
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
/>
</div>
)}
<div className='config-item' data-setting-id='settings.control.showPaginationButtons'>
<span className=''>{_('Show Page Navigation Buttons')}</span>
<input
type='checkbox'
className='toggle'
checked={showPaginationButtons}
onChange={() => setShowPaginationButtons(!showPaginationButtons)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Pagination')} data-setting-id='settings.control.clickToPaginate'>
<SettingsSwitchRow
label={appService?.isMobileApp ? _('Tap to Paginate') : _('Click to Paginate')}
checked={!isDisableClick}
onChange={() => setIsDisableClick(!isDisableClick)}
/>
<SettingsSwitchRow
label={appService?.isMobileApp ? _('Tap Both Sides') : _('Click Both Sides')}
checked={fullscreenClickArea}
disabled={isDisableClick}
onChange={() => setFullscreenClickArea(!fullscreenClickArea)}
data-setting-id='settings.control.clickBothSides'
/>
<SettingsSwitchRow
label={appService?.isMobileApp ? _('Swap Tap Sides') : _('Swap Click Sides')}
checked={swapClickArea}
disabled={isDisableClick || fullscreenClickArea}
onChange={() => setSwapClickArea(!swapClickArea)}
data-setting-id='settings.control.swapClickSides'
/>
<SettingsSwitchRow
label={appService?.isMobileApp ? _('Disable Double Tap') : _('Disable Double Click')}
checked={isDisableDoubleClick}
onChange={() => setIsDisableDoubleClick(!isDisableDoubleClick)}
data-setting-id='settings.control.disableDoubleClick'
/>
{appService?.isMobileApp && (
<SettingsSwitchRow
label={_('Volume Keys for Page Flip')}
checked={volumeKeysToFlip}
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
/>
)}
<SettingsSwitchRow
label={_('Show Page Navigation Buttons')}
checked={showPaginationButtons}
onChange={() => setShowPaginationButtons(!showPaginationButtons)}
data-setting-id='settings.control.showPaginationButtons'
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.control.enableQuickActions'>
<h2 className='mb-2 font-medium'>{_('Annotation Tools')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Enable Quick Actions')}</span>
<input
type='checkbox'
className='toggle'
checked={enableAnnotationQuickActions}
onChange={() => setEnableAnnotationQuickActions(!enableAnnotationQuickActions)}
/>
</div>
<div className='config-item' data-setting-id='settings.control.quickAction'>
<span className=''>{_('Quick Action')}</span>
<Select
value={annotationQuickAction || ''}
onChange={handleSelectAnnotationQuickAction}
options={getQuickActionOptions()}
disabled={!enableAnnotationQuickActions}
/>
</div>
<div className='config-item' data-setting-id='settings.control.copyToNotebook'>
<span className=''>{_('Copy to Notebook')}</span>
<input
type='checkbox'
className='toggle'
checked={copyToNotebook}
onChange={() => setCopyToNotebook(!copyToNotebook)}
/>
</div>
</div>
</div>
</div>
<BoxedList
title={_('Annotation Tools')}
data-setting-id='settings.control.enableQuickActions'
>
<SettingsSwitchRow
label={_('Enable Quick Actions')}
checked={enableAnnotationQuickActions}
onChange={() => setEnableAnnotationQuickActions(!enableAnnotationQuickActions)}
/>
<SettingsRow label={_('Quick Action')} data-setting-id='settings.control.quickAction'>
<SettingsSelect
value={annotationQuickAction || ''}
onChange={handleSelectAnnotationQuickAction}
ariaLabel={_('Quick Action')}
options={getQuickActionOptions()}
disabled={!enableAnnotationQuickActions}
/>
</SettingsRow>
<SettingsSwitchRow
label={_('Copy to Notebook')}
checked={copyToNotebook}
onChange={() => setCopyToNotebook(!copyToNotebook)}
data-setting-id='settings.control.copyToNotebook'
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.control.pagingAnimation'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
checked={animated}
onChange={() => setAnimated(!animated)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Animation')} data-setting-id='settings.control.pagingAnimation'>
<SettingsSwitchRow
label={_('Paging Animation')}
checked={animated}
onChange={() => setAnimated(!animated)}
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.control.device'>
<h2 className='mb-2 font-medium'>{_('Device')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
{(appService?.isAndroidApp || appService?.appPlatform === 'web') && (
<div className='config-item' data-setting-id='settings.control.einkMode'>
<span className=''>{_('E-Ink Mode')}</span>
<input
type='checkbox'
className='toggle'
checked={isEink}
onChange={() => setIsEink(!isEink)}
/>
</div>
)}
{(appService?.isAndroidApp || appService?.appPlatform === 'web') && (
<div className='config-item' data-setting-id='settings.control.colorEinkMode'>
<span className=''>{_('Color E-Ink Mode')}</span>
<input
type='checkbox'
className='toggle'
disabled={!isEink}
checked={isColorEink}
onChange={() => setIsColorEink(!isColorEink)}
/>
</div>
)}
{appService?.isMobileApp && (
<div className='config-item'>
<span className=''>{_('System Screen Brightness')}</span>
<input
type='checkbox'
className='toggle'
checked={autoScreenBrightness}
onChange={() => setAutoScreenBrightness(!autoScreenBrightness)}
/>
</div>
)}
<div className='config-item' data-setting-id='settings.control.screenWakeLock'>
<span className=''>{_('Keep Screen Awake')}</span>
<input
type='checkbox'
className='toggle'
checked={screenWakeLock}
onChange={() => setScreenWakeLock(!screenWakeLock)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Device')} data-setting-id='settings.control.device'>
{(appService?.isAndroidApp || appService?.appPlatform === 'web') && (
<SettingsSwitchRow
label={_('E-Ink Mode')}
checked={isEink}
onChange={() => setIsEink(!isEink)}
data-setting-id='settings.control.einkMode'
/>
)}
{(appService?.isAndroidApp || appService?.appPlatform === 'web') && (
<SettingsSwitchRow
label={_('Color E-Ink Mode')}
checked={isColorEink}
disabled={!isEink}
onChange={() => setIsColorEink(!isColorEink)}
data-setting-id='settings.control.colorEinkMode'
/>
)}
{appService?.isMobileApp && (
<SettingsSwitchRow
label={_('System Screen Brightness')}
checked={autoScreenBrightness}
onChange={() => setAutoScreenBrightness(!autoScreenBrightness)}
/>
)}
<SettingsSwitchRow
label={_('Keep Screen Awake')}
checked={screenWakeLock}
onChange={() => setScreenWakeLock(!screenWakeLock)}
data-setting-id='settings.control.screenWakeLock'
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.control.allowJavascript'>
<h2 className='mb-2 font-medium'>{_('Security')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-16'>
<div className='flex flex-col gap-1'>
<span className=''>{_('Allow JavaScript')}</span>
<span className='text-xs'>{_('Enable only if you trust the file.')}</span>
</div>
<input
type='checkbox'
className='toggle'
checked={allowScript}
disabled={bookData?.book?.format !== 'EPUB'}
onChange={() => setAllowScript(!allowScript)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Security')} data-setting-id='settings.control.allowJavascript'>
<SettingsSwitchRow
label={_('Allow JavaScript')}
description={_('Enable only if you trust the file.')}
checked={allowScript}
disabled={bookData?.book?.format !== 'EPUB'}
onChange={() => setAllowScript(!allowScript)}
/>
</BoxedList>
</div>
);
};
@@ -11,6 +11,9 @@ import {
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
@@ -33,6 +36,8 @@ import {
getBuiltinWebSearch,
isValidUrlTemplate,
} from '@/services/dictionaries/webSearchTemplates';
import SubPageHeader from './SubPageHeader';
import { Tips } from './primitives';
interface CustomDictionariesProps {
onBack: () => void;
@@ -52,6 +57,32 @@ interface ProviderRow {
reason?: string;
}
// Lock drag movement to the vertical axis — the sortable list is vertical, so
// any horizontal travel is wasted motion and lets the drag preview drift out
// from under the row.
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
// Clamp the drag preview to the SortableContext's container rect so users
// can't drag a row out of the dictionaries card.
const restrictToParentElement: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerNodeRect) return transform;
const value = { ...transform };
if (draggingNodeRect.top + transform.y < containerNodeRect.top) {
value.y = containerNodeRect.top - draggingNodeRect.top;
} else if (
draggingNodeRect.bottom + transform.y >
containerNodeRect.top + containerNodeRect.height
) {
value.y = containerNodeRect.top + containerNodeRect.height - draggingNodeRect.bottom;
}
return value;
};
const dragModifiers: Modifier[] = [restrictToVerticalAxis, restrictToParentElement];
const builtinWebLabel = (id: string, _: (key: string) => string): string => {
const tpl = getBuiltinWebSearch(id);
if (!tpl) return id;
@@ -114,7 +145,7 @@ const SortableRow: React.FC<SortableRowProps> = ({
drag, which keeps the toggle and delete buttons clickable. */}
<button
type='button'
className='btn btn-ghost btn-xs h-7 w-5 cursor-grab touch-none p-0 active:cursor-grabbing'
className='touch-target btn btn-ghost btn-xs h-7 w-5 cursor-grab touch-none p-0 active:cursor-grabbing'
aria-label={_('Drag to reorder')}
title={_('Drag to reorder')}
{...attributes}
@@ -224,6 +255,10 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
// turns the other off so the trailing column never shows two icons at once.
const [isDeleteMode, setIsDeleteMode] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
// Track the row currently under the drag cursor. Used to gate auto-scroll
// off when the drop target is the first or last row — there's nothing
// beyond either end, so scrolling further is just visual noise.
const [dragOverId, setDragOverId] = useState<string | null>(null);
const toggleDeleteMode = () =>
setIsDeleteMode((v) => {
const next = !v;
@@ -482,7 +517,19 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
await saveCustomDictionaries(envConfig);
};
const handleDragStart = (event: DragStartEvent) => {
// Seed dragOverId with the active row — at drag start the active is over
// itself, but onDragOver only fires when the over target *changes*, so we
// need an explicit seed to evaluate "currently at edge" on the first frame.
setDragOverId(String(event.active.id));
};
const handleDragOver = (event: DragOverEvent) => {
setDragOverId(event.over ? String(event.over.id) : null);
};
const handleDragEnd = async (event: DragEndEvent) => {
setDragOverId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const order = [...settings.providerOrder];
@@ -498,54 +545,56 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
await saveCustomDictionaries(envConfig, { publishOrderChange: true });
};
const handleDragCancel = () => setDragOverId(null);
const isDragOverEdge =
dragOverId !== null &&
rows.length > 0 &&
(rows[0]?.id === dragOverId || rows[rows.length - 1]?.id === dragOverId);
return (
<div className='w-full'>
<div className='mb-4 flex h-8 items-center justify-between'>
<div className='breadcrumbs py-1'>
<ul>
<li>
<button className='font-semibold' onClick={onBack}>
{_('Language')}
<SubPageHeader
parentLabel={_('Language')}
currentLabel={_('Dictionaries')}
onBack={onBack}
rightSlot={
hasDeletable ? (
<div className='-me-4 flex items-center gap-1'>
<button
onClick={toggleEditMode}
className='btn btn-ghost btn-sm text-base-content gap-2 px-3'
title={isEditMode ? _('Cancel Edit') : _('Edit Dictionary')}
>
{isEditMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdEdit className='h-5 w-5 min-[800px]:h-4 min-[800px]:w-4' />
{/* Hide label on very narrow screens so the icon-only
button keeps the breadcrumb readable. */}
<span className='hidden min-[800px]:inline'>{_('Edit')}</span>
</>
)}
</button>
</li>
<li className='font-medium'>{_('Dictionaries')}</li>
</ul>
</div>
{hasDeletable && (
<div className='flex items-center gap-1'>
<button
onClick={toggleEditMode}
className='btn btn-ghost btn-sm text-base-content gap-2'
title={isEditMode ? _('Cancel Edit') : _('Edit Dictionary')}
>
{isEditMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdEdit className='h-4 w-4' />
{/* Hide label on very narrow screens so the icon-only
button keeps the breadcrumb readable. */}
<span className='hidden min-[400px]:inline'>{_('Edit')}</span>
</>
)}
</button>
<button
onClick={toggleDeleteMode}
className='btn btn-ghost btn-sm text-base-content gap-2'
title={isDeleteMode ? _('Cancel Delete') : _('Delete Dictionary')}
>
{isDeleteMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdDelete className='h-4 w-4' />
<span className='hidden min-[400px]:inline'>{_('Delete')}</span>
</>
)}
</button>
</div>
)}
</div>
<button
onClick={toggleDeleteMode}
className='btn btn-ghost btn-sm text-base-content gap-2 px-3'
title={isDeleteMode ? _('Cancel Delete') : _('Delete Dictionary')}
>
{isDeleteMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdDelete className='h-5 w-5 min-[800px]:h-4 min-[800px]:w-4' />
<span className='hidden min-[800px]:inline'>{_('Delete')}</span>
</>
)}
</button>
</div>
) : undefined
}
/>
<div className='card border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
@@ -557,7 +606,15 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={dragModifiers}
// Auto-scroll only when the drop target is mid-list. When the
// cursor is currently over the first or last row, there's nowhere
// further to drop, so scrolling the dialog is just noise.
autoScroll={!isDragOverEdge}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
{rows.map((row) => (
@@ -584,9 +641,29 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
type='button'
onClick={handleImport}
disabled={importing}
className='btn btn-outline btn-primary gap-2 normal-case [--animation-btn:0s]'
className={clsx(
'eink-bordered group flex h-11 items-center justify-center gap-2.5',
'border-base-200 bg-base-100 rounded-lg border px-4',
'text-base-content text-sm font-medium',
'transition-colors duration-150',
'hover:border-base-300 hover:bg-base-300/40',
'active:bg-base-200/80',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
'disabled:cursor-not-allowed disabled:opacity-60',
'disabled:hover:border-base-200 disabled:hover:bg-base-100',
)}
>
<MdAdd className='h-5 w-5' />
<span
className={clsx(
'flex h-5 w-5 items-center justify-center rounded-full',
'bg-base-200 text-base-content/60',
'transition-colors duration-150',
'group-hover:bg-base-content group-hover:text-base-100',
'group-disabled:bg-base-200 group-disabled:text-base-content/60',
)}
>
<MdAdd className='h-3.5 w-3.5' />
</span>
<span className='line-clamp-1'>
{importing ? _('Importing…') : _('Import Dictionary')}
</span>
@@ -594,30 +671,37 @@ const CustomDictionaries: React.FC<CustomDictionariesProps> = ({ onBack }) => {
<button
type='button'
onClick={openAddWebSearch}
className='btn btn-outline btn-primary gap-2 normal-case [--animation-btn:0s]'
className={clsx(
'eink-bordered group flex h-11 items-center justify-center gap-2.5',
'border-base-200 bg-base-100 rounded-lg border px-4',
'text-base-content text-sm font-medium',
'transition-colors duration-150',
'hover:border-base-300 hover:bg-base-300/40',
'active:bg-base-200/80',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
)}
>
<MdAdd className='h-5 w-5' />
<span
className={clsx(
'flex h-5 w-5 items-center justify-center rounded-full',
'bg-base-200 text-base-content/60',
'transition-colors duration-150',
'group-hover:bg-base-content group-hover:text-base-100',
)}
>
<MdAdd className='h-3.5 w-3.5' />
</span>
<span className='line-clamp-1'>{_('Add Web Search')}</span>
</button>
</div>
<div className='bg-base-200/40 mt-4 rounded-lg p-3'>
<div className='text-base-content/70 text-xs'>
<div className='mb-1.5 flex items-center gap-1.5 font-medium'>
<MdInfoOutline className='h-3.5 w-3.5' />
{_('Tips')}
</div>
<ul className='list-outside list-disc space-y-0.5 ps-4'>
<li>{_('StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).')}</li>
<li>
{_('MDict bundles use .mdx files; companion .mdd and .css files are optional.')}
</li>
<li>{_('DICT bundles need a .index file and a .dict.dz file.')}</li>
<li>{_('Slob bundles need a .slob file.')}</li>
<li>{_('Select all the bundle files together when importing.')}</li>
</ul>
</div>
</div>
<Tips className='mt-4'>
<li>{_('StarDict bundles need .ifo, .idx, and .dict.dz files (.syn optional).')}</li>
<li>{_('MDict bundles use .mdx files; companion .mdd and .css files are optional.')}</li>
<li>{_('DICT bundles need a .index file and a .dict.dz file.')}</li>
<li>{_('Slob bundles need a .slob file.')}</li>
<li>{_('Select all the bundle files together when importing.')}</li>
</Tips>
{/* Add / edit web-search modal. Lightweight inline `<dialog>` (daisyUI
modal classes); the heavier `Dialog.tsx` is overkill for a 2-field
@@ -11,6 +11,7 @@ import { useFileSelector } from '@/hooks/useFileSelector';
import { saveViewSettings } from '@/helpers/settings';
import { CustomFont, mountCustomFont } from '@/styles/fonts';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import { Tips } from './primitives';
interface CustomFontsProps {
bookKey: string;
@@ -155,21 +156,33 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='card border-primary/50 hover:border-primary/75 group h-12 border-2 transition-colors'>
<button
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
onClick={handleImportFont}
{/* Import Font quiet outlined card matching the surrounding font
family cards' visual weight (border-base-200 bg, hover lifts to
base-200). Replaces the old loud `border-primary/50 text-primary`
CTA styling. eink-bordered keeps the boundary visible in eink. */}
<button
type='button'
onClick={handleImportFont}
className={clsx(
'bg-base-100 eink-bordered group flex h-12 items-center justify-center gap-2 rounded-2xl',
'border-base-200 hover:border-base-300 hover:bg-base-300/40 border',
'text-base-content text-sm font-medium',
'transition-colors duration-150',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
)}
>
<span
className={clsx(
'flex h-5 w-5 items-center justify-center rounded-full',
'bg-base-200 text-base-content/60',
'transition-colors duration-150',
'group-hover:bg-base-content group-hover:text-base-100',
)}
>
<div className='flex items-center gap-2'>
<div className='flex items-center justify-center'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
</div>
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
{_('Import Font')}
</div>
</div>
</button>
</div>
<MdAdd className='h-3.5 w-3.5' />
</span>
<span className='line-clamp-1'>{_('Import Font')}</span>
</button>
{availableFamilies.map((family) => (
<div
@@ -179,7 +192,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
'card h-12 border shadow-sm',
currentFontFamily === family.name
? 'border-primary/50 bg-primary/50'
: `border-base-200 bg-base-200 ${isDeleteMode ? '' : 'cursor-pointer'}`,
: `border-base-200 bg-base-100 ${isDeleteMode ? '' : 'cursor-pointer'}`,
)}
onClick={!isDeleteMode ? () => handleSelectFamily(family) : undefined}
title={family.fonts.map((f) => f.name).join('\n')}
@@ -208,15 +221,10 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
))}
</div>
<div className='bg-base-200/30 my-8 rounded-lg p-4'>
<div className='text-base-content/70 text-sm sm:text-xs'>
<div className='mb-1 indent-2 font-medium'>{_('Tips')}:</div>
<ul className='list-outside list-disc space-y-1 ps-2'>
<li>{_('Supported font formats: .ttf, .otf, .woff, .woff2')}</li>
<li>{_('Custom fonts can be selected from the Font Face menu')}</li>
</ul>
</div>
</div>
<Tips className='mt-6'>
<li>{_('Supported font formats: .ttf, .otf, .woff, .woff2')}</li>
<li>{_('Custom fonts can be selected from the Font Face menu')}</li>
</Tips>
</div>
);
};
@@ -1,6 +1,5 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdChevronRight } from 'react-icons/md';
import {
CJK_EXCLUDE_PATTENS,
@@ -28,6 +27,7 @@ import { isTauriAppPlatform } from '@/services/environment';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { saveViewSettings } from '@/helpers/settings';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { BoxedList, NavigationRow, SettingLabel, SettingsRow } from './primitives';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
import CustomFonts from './CustomFonts';
@@ -73,8 +73,11 @@ const FontFace = ({
}: FontFaceProps) => {
const _ = useTranslation();
return (
<div className={clsx('config-item', className)} data-setting-id={settingId}>
<span className='line-clamp-2 min-w-10'>{label}</span>
<div
className={clsx('flex h-14 items-center justify-between pe-4', className)}
data-setting-id={settingId}
>
<SettingLabel className='min-w-10'>{label}</SettingLabel>
<FontDropdown
family={family}
options={options.map((option) => ({ option, label: _(option) }))}
@@ -284,148 +287,107 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
return (
<div className='my-4 w-full space-y-6'>
<div
<label
data-setting-id='settings.font.overrideBookFont'
className='flex items-center justify-between'
className='flex cursor-pointer items-center justify-between px-4'
>
<h2 className='font-medium'>{_('Override Book Font')}</h2>
<SettingLabel>{_('Override Book Font')}</SettingLabel>
<input
type='checkbox'
className='toggle'
checked={overrideFont}
onChange={() => setOverrideFont(!overrideFont)}
/>
</div>
</label>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Size')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
label={_('Default Font Size')}
value={defaultFontSize}
onChange={setDefaultFontSize}
min={minFontSize}
max={120}
data-setting-id='settings.font.defaultFontSize'
/>
<NumberInput
label={_('Minimum Font Size')}
value={minFontSize}
onChange={setMinFontSize}
min={1}
max={120}
data-setting-id='settings.font.minimumFontSize'
/>
</div>
</div>
</div>
<BoxedList title={_('Font Size')}>
<NumberInput
label={_('Default Font Size')}
value={defaultFontSize}
onChange={setDefaultFontSize}
min={minFontSize}
max={120}
data-setting-id='settings.font.defaultFontSize'
/>
<NumberInput
label={_('Minimum Font Size')}
value={minFontSize}
onChange={setMinFontSize}
min={1}
max={120}
data-setting-id='settings.font.minimumFontSize'
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.font.fontWeight'>
<h2 className='mb-2 font-medium'>{_('Font Weight')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
label={_('Font Weight')}
value={fontWeight}
onChange={setFontWeight}
min={100}
max={900}
step={100}
/>
</div>
</div>
</div>
<BoxedList title={_('Font Weight')} data-setting-id='settings.font.fontWeight'>
<NumberInput
label={_('Font Weight')}
value={fontWeight}
onChange={setFontWeight}
min={100}
max={900}
step={100}
/>
</BoxedList>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Family')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item' data-setting-id='settings.font.defaultFont'>
<span className=''>{_('Default Font')}</span>
<FontDropdown
options={fontFamilyOptions}
selected={defaultFont}
onSelect={setDefaultFont}
onGetFontFamily={handleFontFamilyFont}
/>
</div>
<BoxedList title={_('Font Family')}>
<SettingsRow label={_('Default Font')} data-setting-id='settings.font.defaultFont'>
<FontDropdown
options={fontFamilyOptions}
selected={defaultFont}
onSelect={setDefaultFont}
onGetFontFamily={handleFontFamilyFont}
/>
</SettingsRow>
{(isCJKEnv() || view?.language.isCJK) && (
<FontFace
family='serif'
label={_('CJK Font')}
options={CJKFonts}
selected={defaultCJKFont}
onSelect={setDefaultCJKFont}
data-setting-id='settings.font.cjkFont'
/>
)}
</BoxedList>
{(isCJKEnv() || view?.language.isCJK) && (
<FontFace
className='config-item-top'
family='serif'
label={_('CJK Font')}
options={CJKFonts}
selected={defaultCJKFont}
onSelect={setDefaultCJKFont}
data-setting-id='settings.font.cjkFont'
/>
)}
</div>
</div>
</div>
<BoxedList title={_('Font Face')}>
<FontFace
family='serif'
label={_('Serif Font')}
options={[...customFonts, ...SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SERIF_FONTS]}
moreOptions={sysFonts}
selected={serifFont}
onSelect={setSerifFont}
data-setting-id='settings.font.serifFont'
/>
<FontFace
family='sans-serif'
label={_('Sans-Serif Font')}
options={[
...customFonts,
...SANS_SERIF_FONTS.filter(filterNonFreeFonts),
...CJK_SANS_SERIF_FONTS,
]}
moreOptions={sysFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
data-setting-id='settings.font.sansSerifFont'
/>
<FontFace
family='monospace'
label={_('Monospace Font')}
options={[...customFonts, ...MONOSPACE_FONTS]}
moreOptions={sysFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
data-setting-id='settings.font.monospaceFont'
/>
</BoxedList>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Face')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<FontFace
className='config-item-top'
family='serif'
label={_('Serif Font')}
options={[
...customFonts,
...SERIF_FONTS.filter(filterNonFreeFonts),
...CJK_SERIF_FONTS,
]}
moreOptions={sysFonts}
selected={serifFont}
onSelect={setSerifFont}
data-setting-id='settings.font.serifFont'
/>
<FontFace
family='sans-serif'
label={_('Sans-Serif Font')}
options={[
...customFonts,
...SANS_SERIF_FONTS.filter(filterNonFreeFonts),
...CJK_SANS_SERIF_FONTS,
]}
moreOptions={sysFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
data-setting-id='settings.font.sansSerifFont'
/>
<FontFace
className='config-item-bottom'
family='monospace'
label={_('Monospace Font')}
options={[...customFonts, ...MONOSPACE_FONTS]}
moreOptions={sysFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
data-setting-id='settings.font.monospaceFont'
/>
</div>
</div>
</div>
<div className='w-full' data-setting-id='settings.font.fonts'>
<h2 className='mb-2 font-medium'>{_('Custom Fonts')}</h2>
<div className='card border-base-200 bg-base-100 overflow-hidden border shadow'>
<div className='divide-base-200 divide-y'>
<button
type='button'
className='config-item hover:bg-base-200/40 w-full text-left'
onClick={handleManageCustomFonts}
>
<span>{_('Manage Fonts')}</span>
<MdChevronRight className='text-base-content/60 h-5 w-5' />
</button>
</div>
</div>
</div>
<BoxedList title={_('Custom Fonts')} data-setting-id='settings.font.fonts'>
<NavigationRow title={_('Manage Fonts')} onClick={handleManageCustomFonts} />
</BoxedList>
</div>
);
};
@@ -0,0 +1,189 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdChevronRight } from 'react-icons/md';
import { RiBookOpenLine, RiRssLine, RiBookReadLine, RiBook3Line } from 'react-icons/ri';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomOPDSStore } from '@/store/customOPDSStore';
import { CatalogManager } from '@/app/opds/components/CatalogManager';
import KOSyncForm from './integrations/KOSyncForm';
import ReadwiseForm from './integrations/ReadwiseForm';
import HardcoverForm from './integrations/HardcoverForm';
import SubPageHeader from './SubPageHeader';
import { SectionTitle, SettingLabel } from './primitives';
type SubPage = 'kosync' | 'readwise' | 'hardcover' | 'opds' | null;
/**
* Integrations panel single point of discovery for external service config:
* KOReader Sync, Readwise, Hardcover, and OPDS Catalogs.
*
* Pattern: boxed list of NavigationRows. Each row pushes the panel into an
* inline sub-page (with breadcrumb back-navigation matching the Dictionaries
* pattern) no nested modals.
*
* TODO(design-system): Once we extract BoxedList / NavigationRow primitives,
* this panel and CustomDictionaries should both consume them instead of
* inlining the chassis.
*/
const IntegrationsPanel: React.FC = () => {
const _ = useTranslation();
const { settings, requestedSubPage, setRequestedSubPage } = useSettingsStore();
const opdsCatalogs = useCustomOPDSStore((s) => s.catalogs);
const opdsCount = opdsCatalogs.filter((c) => !c.deletedAt).length;
const [subPage, setSubPage] = useState<SubPage>(null);
// Deep-link consumption: when a caller (e.g. OPDS browser close handler)
// sets `requestedSubPage` in the store before opening the dialog, drill
// straight into that sub-page on mount and clear the request so it doesn't
// stick to the next open. Recognised values match the SubPage union.
useEffect(() => {
if (!requestedSubPage) return;
if (
requestedSubPage === 'kosync' ||
requestedSubPage === 'readwise' ||
requestedSubPage === 'hardcover' ||
requestedSubPage === 'opds'
) {
setSubPage(requestedSubPage);
}
setRequestedSubPage(null);
}, [requestedSubPage, setRequestedSubPage]);
// Sub-page wrapper matches the list-view's `my-4 w-full` so the
// SubPageHeader's "Integrations" label lands at the exact same Y position
// as the list-view's h2 — clicking a row reads as a navigation morph
// rather than a layout shift.
if (subPage === 'kosync')
return (
<div className='my-4 w-full'>
<KOSyncForm onBack={() => setSubPage(null)} />
</div>
);
if (subPage === 'readwise')
return (
<div className='my-4 w-full'>
<ReadwiseForm onBack={() => setSubPage(null)} />
</div>
);
if (subPage === 'hardcover')
return (
<div className='my-4 w-full'>
<HardcoverForm onBack={() => setSubPage(null)} />
</div>
);
if (subPage === 'opds')
return (
<div className='my-4 w-full'>
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('OPDS Catalogs')}
description={_('Browse and download books from online catalogs.')}
onBack={() => setSubPage(null)}
/>
<CatalogManager inSubPage />
</div>
);
const koSyncStatus = settings.kosync?.enabled
? settings.kosync.username
? _('Connected as {{user}}', { user: settings.kosync.username })
: _('Connected')
: _('Not connected');
const readwiseStatus = settings.readwise?.enabled ? _('Connected') : _('Not connected');
const hardcoverStatus = settings.hardcover?.enabled ? _('Connected') : _('Not connected');
const opdsStatus =
opdsCount > 0 ? _('{{count}} catalog', { count: opdsCount }) : _('No catalogs');
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full px-4'>
<h2 className='mb-1.5 text-lg font-semibold tracking-tight'>{_('Integrations')}</h2>
<p className='text-base-content/70 text-sm leading-relaxed'>
{_('Connect Readest to external services for sync, highlights, and catalogs.')}
</p>
</div>
<div className='w-full' data-setting-id='settings.integrations.sync'>
<SectionTitle className='mb-2'>{_('Reading Sync')}</SectionTitle>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
<IntegrationRow
icon={RiBookOpenLine}
title={_('KOReader Sync')}
status={koSyncStatus}
onClick={() => setSubPage('kosync')}
/>
<IntegrationRow
icon={RiBookReadLine}
title={_('Readwise')}
status={readwiseStatus}
onClick={() => setSubPage('readwise')}
/>
<IntegrationRow
icon={RiBook3Line}
title={_('Hardcover')}
status={hardcoverStatus}
onClick={() => setSubPage('hardcover')}
/>
</div>
</div>
</div>
<div className='w-full' data-setting-id='settings.integrations.catalogs'>
<SectionTitle className='mb-2'>{_('Content Sources')}</SectionTitle>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
<IntegrationRow
icon={RiRssLine}
title={_('OPDS Catalogs')}
status={opdsStatus}
onClick={() => setSubPage('opds')}
/>
</div>
</div>
</div>
</div>
);
};
interface IntegrationRowProps {
icon: React.ElementType;
title: string;
status: string;
onClick: () => void;
}
const IntegrationRow: React.FC<IntegrationRowProps> = ({ icon: Icon, title, status, onClick }) => {
return (
<button
type='button'
onClick={onClick}
className={clsx(
'group flex w-full items-center gap-3 px-4 py-3 text-left',
'transition-colors duration-150',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset',
)}
>
<span
className={clsx(
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full',
'bg-base-200 text-base-content/70',
'transition-colors duration-150',
'group-hover:bg-base-300/70',
)}
>
<Icon className='h-5 w-5' />
</span>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<SettingLabel>{title}</SettingLabel>
<span className='text-base-content/65 truncate text-[0.85em]'>{status}</span>
</div>
<MdChevronRight className='text-base-content/50 h-5 w-5 flex-shrink-0' />
</button>
);
};
export default IntegrationsPanel;
@@ -1,6 +1,5 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdChevronRight } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useReaderStore } from '@/store/readerStore';
@@ -18,7 +17,13 @@ import { ConvertChineseVariant } from '@/types/book';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { getDirFromLanguage } from '@/utils/rtl';
import { isCJKEnv } from '@/utils/misc';
import Select from '@/components/Select';
import {
BoxedList,
NavigationRow,
SettingsRow,
SettingsSelect,
SettingsSwitchRow,
} from './primitives';
import CustomDictionaries from './CustomDictionaries';
const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
@@ -268,131 +273,95 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
return (
<div className={clsx('my-4 w-full space-y-6')}>
<div className='w-full' data-setting-id='settings.language.interfaceLanguage'>
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Language')}</span>
<Select
value={getCurrentUILangOption().value}
onChange={handleSelectUILang}
options={getLangOptions(TRANSLATED_LANGS)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Language')} data-setting-id='settings.language.interfaceLanguage'>
<SettingsRow label={_('Language')}>
<SettingsSelect
value={getCurrentUILangOption().value}
onChange={handleSelectUILang}
ariaLabel={_('Language')}
options={getLangOptions(TRANSLATED_LANGS)}
/>
</SettingsRow>
</BoxedList>
<div className='w-full' data-setting-id='settings.language.dictionaries'>
<h2 className='mb-2 font-medium'>{_('Dictionaries')}</h2>
<div className='card border-base-200 bg-base-100 overflow-hidden border shadow'>
<div className='divide-base-200 divide-y'>
<button
type='button'
className='config-item hover:bg-base-200/40 w-full text-left'
onClick={() => setShowCustomDictionaries(true)}
>
<span>{_('Manage Dictionaries')}</span>
<MdChevronRight className='text-base-content/60 h-5 w-5' />
</button>
</div>
</div>
</div>
<BoxedList
title={_('Dictionaries')}
data-setting-id='settings.language.dictionaries'
cardClassName='overflow-hidden'
>
<NavigationRow
title={_('Manage Dictionaries')}
onClick={() => setShowCustomDictionaries(true)}
className='h-14'
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.language.translationEnabled'>
<h2 className='mb-2 font-medium'>{_('Translation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span className=''>{_('Enable Translation')}</span>
<input
type='checkbox'
className='toggle'
checked={translationEnabled}
onChange={() => setTranslationEnabled(!translationEnabled)}
disabled={!bookKey}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Source Text')}</span>
<input
type='checkbox'
className='toggle'
checked={showTranslateSource}
onChange={() => setShowTranslateSource(!showTranslateSource)}
/>
</div>
<div className='config-item' data-setting-id='settings.language.ttsTextTranslation'>
<span className=''>{_('TTS Text')}</span>
<Select
value={ttsReadAloudText}
onChange={handleSelectTTSText}
options={getTTSTextOptions()}
/>
</div>
<div className='config-item' data-setting-id='settings.language.translationProvider'>
<span className=''>{_('Translation Service')}</span>
<Select
value={getCurrentTranslationProviderOption().value}
onChange={handleSelectTranslationProvider}
options={getTranslationProviderOptions()}
/>
</div>
<div className='config-item' data-setting-id='settings.language.targetLanguage'>
<span className=''>{_('Translate To')}</span>
<Select
value={getCurrentTargetLangOption().value}
onChange={handleSelectTargetLang}
options={getLangOptions(TRANSLATOR_LANGS)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Translation')} data-setting-id='settings.language.translationEnabled'>
<SettingsSwitchRow
label={_('Enable Translation')}
checked={translationEnabled}
onChange={() => setTranslationEnabled(!translationEnabled)}
disabled={!bookKey}
/>
<SettingsSwitchRow
label={_('Show Source Text')}
checked={showTranslateSource}
onChange={() => setShowTranslateSource(!showTranslateSource)}
/>
<SettingsRow label={_('TTS Text')} data-setting-id='settings.language.ttsTextTranslation'>
<SettingsSelect
value={ttsReadAloudText}
onChange={handleSelectTTSText}
ariaLabel={_('TTS Text')}
options={getTTSTextOptions()}
/>
</SettingsRow>
<SettingsRow
label={_('Translation Service')}
data-setting-id='settings.language.translationProvider'
>
<SettingsSelect
value={getCurrentTranslationProviderOption().value}
onChange={handleSelectTranslationProvider}
ariaLabel={_('Translation Service')}
options={getTranslationProviderOptions()}
/>
</SettingsRow>
<SettingsRow label={_('Translate To')} data-setting-id='settings.language.targetLanguage'>
<SettingsSelect
value={getCurrentTargetLangOption().value}
onChange={handleSelectTargetLang}
ariaLabel={_('Translate To')}
options={getLangOptions(TRANSLATOR_LANGS)}
/>
</SettingsRow>
</BoxedList>
{(isCJKEnv() || view?.language.isCJK) && (
<div className='w-full' data-setting-id='settings.language.quotationMarks'>
<h2 className='mb-2 font-medium'>{_('Punctuation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item !h-16'>
<div className='flex flex-col gap-1'>
<span className=''>{_('Replace Quotation Marks')}</span>
<span className='text-xs'>{_('Enabled only in vertical layout.')}</span>
</div>
<input
type='checkbox'
className='toggle'
checked={replaceQuotationMarks}
onChange={() => setReplaceQuotationMarks(!replaceQuotationMarks)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Punctuation')} data-setting-id='settings.language.quotationMarks'>
<SettingsSwitchRow
label={_('Replace Quotation Marks')}
description={_('Enabled only in vertical layout.')}
checked={replaceQuotationMarks}
onChange={() => setReplaceQuotationMarks(!replaceQuotationMarks)}
/>
</BoxedList>
)}
{(isCJKEnv() || view?.language.isCJK) && (
<div className='w-full' data-setting-id='settings.language.chineseConversion'>
<h2 className='mb-2 font-medium'>{_('Convert Simplified and Traditional Chinese')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span className=''>{_('Convert Mode')}</span>
<Select
value={getConvertModeOption().value}
onChange={handleSelectConvertMode}
options={getConvertModeOptions()}
/>
</div>
</div>
</div>
</div>
<BoxedList
title={_('Convert Simplified and Traditional Chinese')}
data-setting-id='settings.language.chineseConversion'
>
<SettingsRow label={_('Convert Mode')}>
<SettingsSelect
value={getConvertModeOption().value}
onChange={handleSelectConvertMode}
ariaLabel={_('Convert Mode')}
options={getConvertModeOptions()}
/>
</SettingsRow>
</BoxedList>
)}
</div>
);
@@ -18,7 +18,13 @@ import { saveViewSettings } from '@/helpers/settings';
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
import { MIGHT_BE_RTL_LANGS } from '@/services/constants';
import { SettingsPanelPanelProp } from './SettingsDialog';
import Select from '@/components/Select';
import {
BoxedList,
SettingLabel,
SettingsRow,
SettingsSelect,
SettingsSwitchRow,
} from './primitives';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
@@ -445,9 +451,9 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
<div className='my-4 w-full space-y-6'>
<div
data-setting-id='settings.layout.overrideBookLayout'
className='flex items-center justify-between'
className='flex items-center justify-between px-4'
>
<h2 className='font-medium'>{_('Override Book Layout')}</h2>
<SettingLabel>{_('Override Book Layout')}</SettingLabel>
<input
type='checkbox'
className='toggle'
@@ -458,9 +464,9 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
{mightBeRTLBook && (
<div
data-setting-id='settings.layout.writingMode'
className='flex items-center justify-between'
className='flex items-center justify-between px-4'
>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<SettingLabel>{_('Writing Mode')}</SettingLabel>
<div className='flex gap-4'>
<button
title={_('Default')}
@@ -498,403 +504,313 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
)}
{viewSettings.vertical && (
<div className='w-full' data-setting-id='settings.layout.borderFrame'>
<h2 className='mb-2 font-medium'>{_('Border Frame')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Double Border')}</span>
<input
type='checkbox'
className='toggle'
checked={doubleBorder}
onChange={() => setDoubleBorder(!doubleBorder)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Border Color')}</span>
<div className='flex gap-4'>
<button
className={`btn btn-circle btn-sm bg-red-300 hover:bg-red-500 ${borderColor === 'red' ? 'btn-active !bg-red-500' : ''}`}
onClick={() => setBorderColor('red')}
></button>
<button
className={`btn btn-circle btn-sm bg-black/50 hover:bg-black ${borderColor === 'black' ? 'btn-active !bg-black' : ''}`}
onClick={() => setBorderColor('black')}
></button>
</div>
</div>
<BoxedList title={_('Border Frame')} data-setting-id='settings.layout.borderFrame'>
<SettingsSwitchRow
label={_('Double Border')}
checked={doubleBorder}
onChange={() => setDoubleBorder(!doubleBorder)}
/>
<SettingsRow label={_('Border Color')}>
<div className='flex gap-4'>
<button
className={`btn btn-circle btn-sm bg-red-300 hover:bg-red-500 ${borderColor === 'red' ? 'btn-active !bg-red-500' : ''}`}
onClick={() => setBorderColor('red')}
></button>
<button
className={`btn btn-circle btn-sm bg-black/50 hover:bg-black ${borderColor === 'black' ? 'btn-active !bg-black' : ''}`}
onClick={() => setBorderColor('black')}
></button>
</div>
</div>
</div>
</SettingsRow>
</BoxedList>
)}
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item' data-setting-id='settings.layout.useBookLayout'>
<span className=''>{_('Use Book Layout')}</span>
<input
type='checkbox'
className='toggle'
checked={useBookLayout}
onChange={() => setUseBookLayout(!useBookLayout)}
/>
</div>
<NumberInput
label={_('Paragraph Margin')}
value={paragraphMargin}
onChange={setParagraphMargin}
disabled={useBookLayout}
min={0}
max={4}
step={0.1}
data-setting-id='settings.layout.paragraphMargin'
/>
<NumberInput
label={_('Line Spacing')}
value={lineHeight}
onChange={setLineHeight}
disabled={useBookLayout}
min={1.0}
max={3.0}
step={0.1}
data-setting-id='settings.layout.lineSpacing'
/>
{langCode !== 'zh' && (
<NumberInput
label={_('Word Spacing')}
value={wordSpacing}
onChange={setWordSpacing}
disabled={useBookLayout}
min={-4}
max={8}
step={0.5}
data-setting-id='settings.layout.wordSpacing'
/>
)}
<NumberInput
label={_('Letter Spacing')}
value={letterSpacing}
onChange={setLetterSpacing}
disabled={useBookLayout}
min={-2}
max={4}
step={0.5}
data-setting-id='settings.layout.letterSpacing'
/>
<NumberInput
label={_('Text Indent')}
value={textIndent}
onChange={setTextIndent}
disabled={useBookLayout}
min={-2}
max={4}
step={1}
data-setting-id='settings.layout.paragraphIndent'
/>
<div className='config-item' data-setting-id='settings.layout.fullJustification'>
<span className=''>{_('Full Justification')}</span>
<input
type='checkbox'
className='toggle'
checked={fullJustification}
disabled={useBookLayout}
onChange={() => setFullJustification(!fullJustification)}
/>
</div>
<div className='config-item' data-setting-id='settings.layout.hyphenation'>
<span className=''>{_('Hyphenation')}</span>
<input
type='checkbox'
className='toggle'
checked={hyphenation}
disabled={useBookLayout}
onChange={() => setHyphenation(!hyphenation)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Paragraph')}>
<SettingsSwitchRow
label={_('Use Book Layout')}
checked={useBookLayout}
onChange={() => setUseBookLayout(!useBookLayout)}
data-setting-id='settings.layout.useBookLayout'
/>
<NumberInput
label={_('Paragraph Margin')}
value={paragraphMargin}
onChange={setParagraphMargin}
disabled={useBookLayout}
min={0}
max={4}
step={0.1}
data-setting-id='settings.layout.paragraphMargin'
/>
<NumberInput
label={_('Line Spacing')}
value={lineHeight}
onChange={setLineHeight}
disabled={useBookLayout}
min={1.0}
max={3.0}
step={0.1}
data-setting-id='settings.layout.lineSpacing'
/>
{langCode !== 'zh' && (
<NumberInput
label={_('Word Spacing')}
value={wordSpacing}
onChange={setWordSpacing}
disabled={useBookLayout}
min={-4}
max={8}
step={0.5}
data-setting-id='settings.layout.wordSpacing'
/>
)}
<NumberInput
label={_('Letter Spacing')}
value={letterSpacing}
onChange={setLetterSpacing}
disabled={useBookLayout}
min={-2}
max={4}
step={0.5}
data-setting-id='settings.layout.letterSpacing'
/>
<NumberInput
label={_('Text Indent')}
value={textIndent}
onChange={setTextIndent}
disabled={useBookLayout}
min={-2}
max={4}
step={1}
data-setting-id='settings.layout.paragraphIndent'
/>
<SettingsSwitchRow
label={_('Full Justification')}
checked={fullJustification}
disabled={useBookLayout}
onChange={() => setFullJustification(!fullJustification)}
data-setting-id='settings.layout.fullJustification'
/>
<SettingsSwitchRow
label={_('Hyphenation')}
checked={hyphenation}
disabled={useBookLayout}
onChange={() => setHyphenation(!hyphenation)}
data-setting-id='settings.layout.hyphenation'
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.layout.pageMargins'>
<h2 className='mb-2 font-medium'>{_('Page')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
label={_('Top Margin (px)')}
value={showHeader && !isVertical ? marginTopPx : compactMarginTopPx}
onChange={showHeader && !isVertical ? setMarginTopPx : setCompactMarginTopPx}
min={
showHeader && !isVertical
? Math.max(0, Math.round((44 - gridInsets.top) / 4) * 4)
: 0
}
max={88}
step={4}
/>
<NumberInput
label={_('Bottom Margin (px)')}
value={showFooter && !isVertical ? marginBottomPx : compactMarginBottomPx}
onChange={showFooter && !isVertical ? setMarginBottomPx : setCompactMarginBottomPx}
min={
showFooter && !isVertical
? Math.max(0, Math.round((44 - gridInsets.bottom) / 4) * 4)
: 0
}
max={88}
step={4}
/>
<NumberInput
label={_('Left Margin (px)')}
value={showFooter && isVertical ? marginLeftPx : compactMarginLeftPx}
onChange={showFooter && isVertical ? setMarginLeftPx : setCompactMarginLeftPx}
min={0}
max={88}
step={4}
/>
<NumberInput
label={_('Right Margin (px)')}
value={showHeader && isVertical ? marginRightPx : compactMarginRightPx}
onChange={showHeader && isVertical ? setMarginRightPx : setCompactMarginRightPx}
min={0}
max={88}
step={4}
/>
<NumberInput
label={_('Column Gap (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
max={30}
data-setting-id='settings.layout.pageGap'
/>
<NumberInput
label={_('Maximum Number of Columns')}
value={maxColumnCount}
onChange={setMaxColumnCount}
min={1}
max={4}
data-setting-id='settings.layout.maxColumnCount'
/>
<NumberInput
label={viewSettings.vertical ? _('Maximum Column Height') : _('Maximum Column Width')}
value={maxInlineSize}
onChange={setMaxInlineSize}
disabled={false}
min={200}
max={9999}
step={50}
data-setting-id='settings.layout.maxInlineSize'
/>
<NumberInput
label={viewSettings.vertical ? _('Maximum Column Width') : _('Maximum Column Height')}
value={maxBlockSize}
onChange={setMaxBlockSize}
disabled={false}
min={400}
max={9999}
step={50}
data-setting-id='settings.layout.maxBlockSize'
/>
<div className='config-item'>
<span className=''>{_('Apply also in Scrolled Mode')}</span>
<input
type='checkbox'
className='toggle'
checked={showMarginsOnScroll}
onChange={() => setShowMarginsOnScroll(!showMarginsOnScroll)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Page')} data-setting-id='settings.layout.pageMargins'>
<NumberInput
label={_('Top Margin (px)')}
value={showHeader && !isVertical ? marginTopPx : compactMarginTopPx}
onChange={showHeader && !isVertical ? setMarginTopPx : setCompactMarginTopPx}
min={
showHeader && !isVertical ? Math.max(0, Math.round((44 - gridInsets.top) / 4) * 4) : 0
}
max={88}
step={4}
/>
<NumberInput
label={_('Bottom Margin (px)')}
value={showFooter && !isVertical ? marginBottomPx : compactMarginBottomPx}
onChange={showFooter && !isVertical ? setMarginBottomPx : setCompactMarginBottomPx}
min={
showFooter && !isVertical
? Math.max(0, Math.round((44 - gridInsets.bottom) / 4) * 4)
: 0
}
max={88}
step={4}
/>
<NumberInput
label={_('Left Margin (px)')}
value={showFooter && isVertical ? marginLeftPx : compactMarginLeftPx}
onChange={showFooter && isVertical ? setMarginLeftPx : setCompactMarginLeftPx}
min={0}
max={88}
step={4}
/>
<NumberInput
label={_('Right Margin (px)')}
value={showHeader && isVertical ? marginRightPx : compactMarginRightPx}
onChange={showHeader && isVertical ? setMarginRightPx : setCompactMarginRightPx}
min={0}
max={88}
step={4}
/>
<NumberInput
label={_('Column Gap (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
max={30}
data-setting-id='settings.layout.pageGap'
/>
<NumberInput
label={_('Maximum Number of Columns')}
value={maxColumnCount}
onChange={setMaxColumnCount}
min={1}
max={4}
data-setting-id='settings.layout.maxColumnCount'
/>
<NumberInput
label={viewSettings.vertical ? _('Maximum Column Height') : _('Maximum Column Width')}
value={maxInlineSize}
onChange={setMaxInlineSize}
disabled={false}
min={200}
max={9999}
step={50}
data-setting-id='settings.layout.maxInlineSize'
/>
<NumberInput
label={viewSettings.vertical ? _('Maximum Column Width') : _('Maximum Column Height')}
value={maxBlockSize}
onChange={setMaxBlockSize}
disabled={false}
min={400}
max={9999}
step={50}
data-setting-id='settings.layout.maxBlockSize'
/>
<SettingsSwitchRow
label={_('Apply also in Scrolled Mode')}
checked={showMarginsOnScroll}
onChange={() => setShowMarginsOnScroll(!showMarginsOnScroll)}
/>
</BoxedList>
<div className='w-full' data-setting-id='settings.layout.showHeader'>
<h2 className='mb-2 font-medium'>{_('Header & Footer')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Show Header')}</span>
<input
type='checkbox'
className='toggle'
checked={showHeader}
onChange={() => setShowHeader(!showHeader)}
/>
</div>
<div className='config-item' data-setting-id='settings.layout.showFooter'>
<span className=''>{_('Show Footer')}</span>
<input
type='checkbox'
className='toggle'
checked={showFooter}
onChange={() => setShowFooter(!showFooter)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Remaining Time')}</span>
<input
type='checkbox'
className='toggle'
checked={showRemainingTime}
disabled={!showFooter}
onChange={() => {
if (!showRemainingTime) {
setShowRemainingTime(true);
setShowRemainingPages(false);
} else {
setShowRemainingTime(false);
}
}}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Remaining Pages')}</span>
<input
type='checkbox'
className='toggle'
checked={showRemainingPages}
disabled={!showFooter}
onChange={() => {
if (!showRemainingPages) {
setShowRemainingPages(true);
setShowRemainingTime(false);
} else {
setShowRemainingPages(false);
}
}}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Reading Progress')}</span>
<input
type='checkbox'
className='toggle'
checked={showProgressInfo}
disabled={!showFooter}
onChange={() => setShowProgressInfo(!showProgressInfo)}
/>
</div>
<div className='config-item' data-setting-id='settings.layout.progressDisplay'>
<span className=''>{_('Reading Progress Style')}</span>
<Select
value={progressStyle}
onChange={(e) => setProgressStyle(e.target.value as 'percentage' | 'fraction')}
options={[
{ value: 'fraction', label: _('Page Number') },
{ value: 'percentage', label: _('Percentage') },
]}
disabled={!showProgressInfo}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Current Time')}</span>
<input
type='checkbox'
className='toggle'
checked={showCurrentTime}
disabled={!showFooter}
onChange={() => setShowCurrentTime(!showCurrentTime)}
/>
</div>
{showCurrentTime && (
<div className='config-item'>
<span className=''>{_('Use 24 Hour Clock')}</span>
<input
type='checkbox'
className='toggle'
checked={use24HourClock}
disabled={!showFooter}
onChange={() => setUse24HourClock(!use24HourClock)}
/>
</div>
)}
<div className='config-item'>
<span className=''>{_('Show Current Battery Status')}</span>
<input
type='checkbox'
className='toggle'
checked={showCurrentBatteryStatus}
disabled={!showFooter}
onChange={() => setShowCurrentBatteryStatus(!showCurrentBatteryStatus)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Battery Percentage')}</span>
<input
type='checkbox'
className='toggle'
checked={showBatteryPercentage}
disabled={!showFooter || !showCurrentBatteryStatus}
onChange={() => setShowBatteryPercentage(!showBatteryPercentage)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Tap to Toggle Footer')}</span>
<input
type='checkbox'
className='toggle'
checked={tapToToggleFooter}
disabled={!showFooter}
onChange={() => setTapToToggleFooter(!tapToToggleFooter)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Apply also in Scrolled Mode')}</span>
<input
type='checkbox'
className='toggle'
checked={showBarsOnScroll}
onChange={() => setShowBarsOnScroll(!showBarsOnScroll)}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Header & Footer')} data-setting-id='settings.layout.showHeader'>
<SettingsSwitchRow
label={_('Show Header')}
checked={showHeader}
onChange={() => setShowHeader(!showHeader)}
/>
<SettingsSwitchRow
label={_('Show Footer')}
checked={showFooter}
onChange={() => setShowFooter(!showFooter)}
data-setting-id='settings.layout.showFooter'
/>
<SettingsSwitchRow
label={_('Show Remaining Time')}
checked={showRemainingTime}
disabled={!showFooter}
onChange={() => {
if (!showRemainingTime) {
setShowRemainingTime(true);
setShowRemainingPages(false);
} else {
setShowRemainingTime(false);
}
}}
/>
<SettingsSwitchRow
label={_('Show Remaining Pages')}
checked={showRemainingPages}
disabled={!showFooter}
onChange={() => {
if (!showRemainingPages) {
setShowRemainingPages(true);
setShowRemainingTime(false);
} else {
setShowRemainingPages(false);
}
}}
/>
<SettingsSwitchRow
label={_('Show Reading Progress')}
checked={showProgressInfo}
disabled={!showFooter}
onChange={() => setShowProgressInfo(!showProgressInfo)}
/>
<SettingsRow
label={_('Reading Progress Style')}
data-setting-id='settings.layout.progressDisplay'
>
<SettingsSelect
value={progressStyle}
onChange={(e) => setProgressStyle(e.target.value as 'percentage' | 'fraction')}
ariaLabel={_('Reading Progress Style')}
options={[
{ value: 'fraction', label: _('Page Number') },
{ value: 'percentage', label: _('Percentage') },
]}
disabled={!showProgressInfo}
/>
</SettingsRow>
<SettingsSwitchRow
label={_('Show Current Time')}
checked={showCurrentTime}
disabled={!showFooter}
onChange={() => setShowCurrentTime(!showCurrentTime)}
/>
{showCurrentTime && (
<SettingsSwitchRow
label={_('Use 24 Hour Clock')}
checked={use24HourClock}
disabled={!showFooter}
onChange={() => setUse24HourClock(!use24HourClock)}
/>
)}
<SettingsSwitchRow
label={_('Show Current Battery Status')}
checked={showCurrentBatteryStatus}
disabled={!showFooter}
onChange={() => setShowCurrentBatteryStatus(!showCurrentBatteryStatus)}
/>
<SettingsSwitchRow
label={_('Show Battery Percentage')}
checked={showBatteryPercentage}
disabled={!showFooter || !showCurrentBatteryStatus}
onChange={() => setShowBatteryPercentage(!showBatteryPercentage)}
/>
<SettingsSwitchRow
label={_('Tap to Toggle Footer')}
checked={tapToToggleFooter}
disabled={!showFooter}
onChange={() => setTapToToggleFooter(!tapToToggleFooter)}
/>
<SettingsSwitchRow
label={_('Apply also in Scrolled Mode')}
checked={showBarsOnScroll}
onChange={() => setShowBarsOnScroll(!showBarsOnScroll)}
/>
</BoxedList>
{appService?.hasOrientationLock && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Screen')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Orientation')}</span>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('auto')}
>
<MdOutlineScreenRotation />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Portrait')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'portrait' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('portrait')}
>
<IoPhonePortraitOutline />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Landscape')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'landscape' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('landscape')}
>
<IoPhoneLandscapeOutline />
</button>
</div>
</div>
<BoxedList title={_('Screen')}>
<SettingsRow label={_('Orientation')}>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('auto')}
>
<MdOutlineScreenRotation />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Portrait')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'portrait' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('portrait')}
>
<IoPhonePortraitOutline />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Landscape')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${screenOrientation === 'landscape' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setScreenOrientation('landscape')}
>
<IoPhoneLandscapeOutline />
</button>
</div>
</div>
</div>
</div>
</SettingsRow>
</BoxedList>
)}
</div>
);
@@ -10,6 +10,7 @@ import { SettingsPanelPanelProp } from './SettingsDialog';
import { saveViewSettings } from '@/helpers/settings';
import { validateCSS, formatCSS } from '@/utils/css';
import { getStyles } from '@/utils/style';
import { BoxedList } from './primitives';
type CSSType = 'book' | 'reader';
@@ -143,14 +144,13 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
settingId?: string,
) => (
<div className='w-full' data-setting-id={settingId}>
<h2 className='mb-2 font-medium' aria-label={_(title)}>
{_(title)}
</h2>
<div
className={`card border-base-200 bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}
>
<div className='relative p-1'>
<div className='w-full'>
<BoxedList title={_(title)} data-setting-id={settingId} innerClassName='!ps-0'>
{/* Single full-width child instead of typical settings rows the
textarea owns the whole card surface. Apply button overlays at
the bottom-trailing corner; visible only when there are unsaved
edits and no validation error. */}
<div className={clsx('relative p-1', error && 'ring-error/60 rounded-2xl ring-1')}>
<textarea
ref={textareaRef}
className={clsx(
@@ -169,9 +169,10 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
<button
className={clsx(
'btn btn-ghost bg-base-200 absolute bottom-2 right-4 h-8 min-h-8 px-4 py-2',
'hover:bg-base-300 bg-base-200 absolute bottom-2 end-4 inline-flex h-8 items-center rounded-md px-3 text-xs font-medium transition-colors duration-150',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
saved ? 'hidden' : '',
error ? 'btn-disabled' : '',
error ? 'pointer-events-none opacity-50' : '',
)}
onClick={() => applyStyles(type)}
disabled={!!error}
@@ -179,8 +180,8 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
{_('Apply')}
</button>
</div>
</div>
{error && <p className='mt-1 text-sm text-red-500'>{error}</p>}
</BoxedList>
{error && <p className='text-error mt-1 ps-4 text-sm'>{error}</p>}
</div>
);
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { FiMinus, FiPlus } from 'react-icons/fi';
import { useTranslation } from '@/hooks/useTranslation';
import { SettingLabel } from './primitives';
interface NumberInputProps {
className?: string;
@@ -64,8 +65,11 @@ const NumberInput: React.FC<NumberInputProps> = ({
};
return (
<div className={clsx('config-item', className)} data-setting-id={settingId}>
<span className='text-base-content line-clamp-2'>{label}</span>
<div
className={clsx('flex h-14 items-center justify-between pe-4', className)}
data-setting-id={settingId}
>
<SettingLabel>{label}</SettingLabel>
{iconSize && <span style={{ minWidth: `${iconSize}px` }} />}
<div className='text-base-content flex items-center gap-2'>
<form onSubmit={handleSubmit}>
@@ -5,19 +5,26 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTranslation } from '@/hooks/useTranslation';
import { useCommandPalette } from '@/components/command-palette';
import { RiFontSize } from 'react-icons/ri';
import { RiFontSize, RiShareLine } from 'react-icons/ri';
import { RiDashboardLine, RiTranslate } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { PiDotsThreeVerticalBold, PiRobot, PiSpeakerHigh } from 'react-icons/pi';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
import { MdArrowBackIosNew, MdArrowForwardIos, MdClose } from 'react-icons/md';
import {
MdArrowBackIosNew,
MdArrowForwardIos,
MdChevronLeft,
MdChevronRight,
MdClose,
} from 'react-icons/md';
import { FiSearch } from 'react-icons/fi';
import { getDirFromUILanguage } from '@/utils/rtl';
import { getCommandPaletteShortcut } from '@/services/environment';
import FontPanel from './FontPanel';
import LayoutPanel from './LayoutPanel';
import ColorPanel from './ColorPanel';
import IntegrationsPanel from './IntegrationsPanel';
import Dropdown from '@/components/Dropdown';
import Dialog from '@/components/Dialog';
import DialogMenu from './DialogMenu';
@@ -35,6 +42,7 @@ export type SettingsPanelType =
| 'TTS'
| 'Language'
| 'AI'
| 'Integrations'
| 'Custom';
export type SettingsPanelPanelProp = {
bookKey: string;
@@ -56,8 +64,15 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const tabsRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
const { setFontPanelView, setSettingsDialogOpen, activeSettingsItemId, setActiveSettingsItemId } =
useSettingsStore();
const [canScrollTabsForward, setCanScrollTabsForward] = useState(false);
const {
setFontPanelView,
setSettingsDialogOpen,
activeSettingsItemId,
setActiveSettingsItemId,
requestedPanel,
setRequestedPanel,
} = useSettingsStore();
const { open: openCommandPalette } = useCommandPalette();
const handleOpenCommandPalette = () => {
@@ -102,6 +117,11 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
label: _('AI Assistant'),
disabled: process.env.NODE_ENV === 'production',
},
{
tab: 'Integrations',
icon: RiShareLine,
label: _('Integrations'),
},
{
tab: 'Custom',
icon: IoAccessibilityOutline,
@@ -110,6 +130,13 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
] as TabConfig[];
const [activePanel, setActivePanel] = useState<SettingsPanelType>(() => {
// Deep-link: if a caller asked for a specific panel before opening the
// dialog, honor that for the initial state. The store-clear lives in
// a useEffect below so we never call a zustand setter during render
// (would warn "Cannot update a component while rendering another").
if (requestedPanel && tabConfig.some((tab) => tab.tab === requestedPanel)) {
return requestedPanel as SettingsPanelType;
}
const lastPanel = localStorage.getItem('lastConfigPanel');
if (lastPanel && tabConfig.some((tab) => tab.tab === lastPanel)) {
return lastPanel as SettingsPanelType;
@@ -117,6 +144,15 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return 'Font' as SettingsPanelType;
});
// Clear the deep-link request after the initial render has consumed it,
// so the next dialog open doesn't stick on the same panel. Effect runs
// once on mount; subsequent callers must call setRequestedPanel before
// opening the dialog again.
useEffect(() => {
if (requestedPanel) setRequestedPanel(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSetActivePanel = (tab: SettingsPanelType) => {
setActivePanel(tab);
setFontPanelView('main-fonts');
@@ -143,6 +179,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
TTS: null,
Language: null,
AI: null,
Integrations: null,
Custom: null,
});
@@ -176,6 +213,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
tts: 'TTS',
language: 'Language',
ai: 'AI',
integrations: 'Integrations',
custom: 'Custom',
};
const panelKey = parts[1]?.toLowerCase();
@@ -230,22 +268,44 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowAllTabLabels(!hideLabel);
};
checkButtonWidths();
// |scrollLeft| (Math.abs) handles RTL, where modern browsers use 0 → -max
// for scrolling toward the visual-leading end of the strip.
const updateScrollState = () => {
const overflow = container.scrollWidth - container.clientWidth;
const scrolled = Math.abs(container.scrollLeft);
setCanScrollTabsForward(overflow > 1 && scrolled < overflow - 1);
};
const resizeObserver = new ResizeObserver(checkButtonWidths);
const recompute = () => {
checkButtonWidths();
updateScrollState();
};
recompute();
const resizeObserver = new ResizeObserver(recompute);
resizeObserver.observe(container);
const mutationObserver = new MutationObserver(checkButtonWidths);
const mutationObserver = new MutationObserver(recompute);
mutationObserver.observe(container, {
subtree: true,
characterData: true,
});
container.addEventListener('scroll', updateScrollState, { passive: true });
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
container.removeEventListener('scroll', updateScrollState);
};
}, [setFontPanelView]);
const handleScrollTabsForward = () => {
const container = tabsRef.current;
if (!container) return;
const amount = container.clientWidth * 0.7;
container.scrollBy({ left: isRtl ? -amount : amount, behavior: 'smooth' });
};
const currentPanel = tabConfig.find((tab) => tab.tab === activePanel);
const windowControls = (
@@ -290,13 +350,17 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
className='modal-open'
bgClassName={bookKey ? 'sm:!bg-black/20' : 'sm:!bg-black/50'}
boxClassName={clsx(
'sm:min-w-[520px] overflow-hidden',
'sm:min-w-[520px] overflow-hidden bg-base-200',
appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4',
)}
snapHeight={appService?.isMobile ? 0.7 : undefined}
// Settings panels can be tall (Layout / Color especially); native
// scrollbars vanish on Android/iOS webviews, so use OverlayScrollbars
// to keep a visible, theme-aware track on every platform.
useOverlayScroll
header={
<div className='flex w-full flex-col items-center'>
<div className='flex w-full items-center justify-center pb-2 sm:hidden'>
<div className='-mt-2 flex w-full items-center justify-center pb-2 sm:hidden'>
<button
tabIndex={-1}
aria-label={_('Close')}
@@ -348,6 +412,18 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</button>
))}
</div>
{canScrollTabsForward && (
<button
type='button'
onClick={handleScrollTabsForward}
aria-label={_('Scroll tabs')}
title={_('Scroll tabs')}
tabIndex={-1}
className='btn btn-ghost btn-circle flex h-8 min-h-8 w-8 shrink-0 items-center justify-center p-0'
>
{isRtl ? <MdChevronLeft /> : <MdChevronRight />}
</button>
)}
<div className='hidden sm:flex'>{windowControls}</div>
</div>
</div>
@@ -392,6 +468,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
)}
{activePanel === 'AI' && <AIPanel />}
{activePanel === 'Integrations' && <IntegrationsPanel />}
{activePanel === 'Custom' && (
<MiscPanel
bookKey={bookKey}
@@ -0,0 +1,68 @@
import clsx from 'clsx';
import React from 'react';
import { MdChevronRight } from 'react-icons/md';
interface SubPageHeaderProps {
/** Label of the parent panel (the "back" target). */
parentLabel: string;
/** Label of the current sub-page. */
currentLabel: string;
/**
* One-line explanation of what this sub-page does. Required by DESIGN.md §2.8
* for every sub-page; skip only when the breadcrumb already says everything.
* Accepts ReactNode so callers can embed `<a>` links etc.
*/
description?: React.ReactNode;
/** Click handler for the parent breadcrumb (returns to list view). */
onBack: () => void;
/** Optional trailing content (e.g. Edit / Delete toggles). */
rightSlot?: React.ReactNode;
}
/**
* Settings sub-page header. Canonical shape for any sub-page that pushes off a
* parent panel's boxed list Dictionaries, Integrations sub-pages, future
* Reading Statistics drill-downs, etc. See DESIGN.md §2.8 / §5 / §6.
*
* The parent label uses the same `text-lg font-semibold tracking-tight` as the
* parent panel's h2 so the word stays in place visually as the user navigates
* in/out the only change is a chevron + current page appearing alongside (a
* "navigation morph" rather than a layout shift).
*
* The description renders below the breadcrumb row, inheriting the
* `.settings-content` body size (14px desktop / 16px mobile).
*/
const SubPageHeader: React.FC<SubPageHeaderProps> = ({
parentLabel,
currentLabel,
description,
onBack,
rightSlot,
}) => {
return (
<div className={clsx(description ? 'mb-6' : 'mb-4', 'px-4')}>
<div className='mb-1.5 flex w-full items-center justify-between gap-2'>
<div className='flex min-w-0 items-center gap-2 text-lg font-semibold tracking-tight'>
<button
type='button'
onClick={onBack}
className='hover:text-primary transition-colors duration-150 focus-visible:underline focus-visible:outline-none'
>
{parentLabel}
</button>
<MdChevronRight
aria-hidden='true'
className='text-base-content/40 h-5 w-5 flex-shrink-0'
/>
<span className='text-base-content/70 truncate'>{currentLabel}</span>
</div>
{rightSlot}
</div>
{/* No explicit text-sm description inherits .settings-content
font-size (14px desktop / 16px mobile per src/styles/globals.css). */}
{description && <p className='text-base-content/70 leading-relaxed'>{description}</p>}
</div>
);
};
export default SubPageHeader;
@@ -7,8 +7,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '@/helpers/settings';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { TTSMediaMetadataMode } from '@/services/tts/types';
import { BoxedList, SettingsRow, SettingsSelect } from './primitives';
import TTSHighlightStyleEditor, { TTSHighlightStyle } from './color/TTSHighlightStyleEditor';
import Select from '../Select';
const TTSPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
@@ -88,27 +88,20 @@ const TTSPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }
data-setting-id='settings.tts.ttsHighlightStyle'
/>
<div className='w-full' data-setting-id='settings.tts.mediaMetadata'>
<h2 className='mb-2 font-medium'>{_('Media Info')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-16'>
<div className='flex flex-col gap-1'>
<span>{_('Update Frequency')}</span>
</div>
<Select
value={ttsMediaMetadata}
onChange={handleMediaMetadataChange}
options={[
{ value: 'sentence', label: _('Every Sentence') },
{ value: 'paragraph', label: _('Every Paragraph') },
{ value: 'chapter', label: _('Every Chapter') },
]}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Media Info')} data-setting-id='settings.tts.mediaMetadata'>
<SettingsRow label={_('Update Frequency')}>
<SettingsSelect
value={ttsMediaMetadata}
onChange={handleMediaMetadataChange}
ariaLabel={_('Update Frequency')}
options={[
{ value: 'sentence', label: _('Every Sentence') },
{ value: 'paragraph', label: _('Every Paragraph') },
{ value: 'chapter', label: _('Every Chapter') },
]}
/>
</SettingsRow>
</BoxedList>
</div>
);
};
@@ -2,7 +2,7 @@ import React from 'react';
import { MdRadioButtonChecked, MdClose, MdAdd, MdPlayCircleOutline } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import Select from '@/components/Select';
import { BoxedList, SectionTitle, SettingsRow, SettingsSelect } from '../primitives';
interface Texture {
id: string;
@@ -44,7 +44,7 @@ const BackgroundTextureSelector: React.FC<BackgroundTextureSelectorProps> = ({
return (
<div>
<h2 className='mb-2 font-medium'>{_('Background Image')}</h2>
<SectionTitle className='mb-2'>{_('Background Image')}</SectionTitle>
<div className='mb-4 grid grid-cols-2 gap-4'>
{allTextures.map((texture) => (
// The swatch is a div (not a <button>) so the inner Delete
@@ -63,10 +63,13 @@ const BackgroundTextureSelector: React.FC<BackgroundTextureSelectorProps> = ({
onTextureSelect(texture.id);
}
}}
className={`bg-base-100 relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-all ${
selectedTextureId === texture.id
? 'ring-2 ring-indigo-500 ring-offset-2'
: 'border-base-300'
// Selected texture gets a 2px border in `base-content` (the
// app's primary text color — white on dark mode, near-black on
// light mode). Guaranteed contrast against any texture image.
// Inactive cards keep `border-base-300` so the slot doesn't
// shift on selection change.
className={`bg-base-100 relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-colors ${
selectedTextureId === texture.id ? 'border-base-content' : 'border-base-300'
}`}
style={{
backgroundImage: texture.loaded ? `url("${texture.blobUrl || texture.url}")` : 'none',
@@ -123,11 +126,10 @@ const BackgroundTextureSelector: React.FC<BackgroundTextureSelectorProps> = ({
</div>
</div>
{/* Background Image Settings */}
{/* Background Image Settings — boxed list once a texture is selected */}
{selectedTextureId !== 'none' && (
<div className='card border-base-200 bg-base-100 space-y-4 border p-4 shadow'>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Opacity')}</span>
<BoxedList>
<SettingsRow label={_('Opacity')}>
<div className='flex items-center gap-2'>
<input
type='range'
@@ -138,25 +140,24 @@ const BackgroundTextureSelector: React.FC<BackgroundTextureSelectorProps> = ({
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
className='range range-sm w-32'
/>
<span className='w-12 text-right text-sm'>
<span className='text-base-content/70 w-12 text-end text-sm'>
{Math.round(backgroundOpacity * 100)}%
</span>
</div>
</div>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Size')}</span>
<Select
</SettingsRow>
<SettingsRow label={_('Size')}>
<SettingsSelect
value={backgroundSize}
onChange={(e) => onSizeChange(e.target.value)}
ariaLabel={_('Size')}
options={[
{ value: 'auto', label: _('Auto') },
{ value: 'cover', label: _('Cover') },
{ value: 'contain', label: _('Contain') },
]}
/>
</div>
</div>
</SettingsRow>
</BoxedList>
)}
</div>
);
@@ -1,13 +1,14 @@
import React from 'react';
import { CODE_LANGUAGES, CodeLanguage } from '@/utils/highlightjs';
import { useTranslation } from '@/hooks/useTranslation';
import Select from '@/components/Select';
import { BoxedList, SettingsRow, SettingsSelect, SettingsSwitchRow } from '../primitives';
interface CodeHighlightingSettingsProps {
codeHighlighting: boolean;
codeLanguage: string;
onToggle: (enabled: boolean) => void;
onLanguageChange: (language: CodeLanguage) => void;
'data-setting-id'?: string;
}
const CodeHighlightingSettings: React.FC<CodeHighlightingSettingsProps> = ({
@@ -15,39 +16,30 @@ const CodeHighlightingSettings: React.FC<CodeHighlightingSettingsProps> = ({
codeLanguage,
onToggle,
onLanguageChange,
'data-setting-id': dataSettingId,
}) => {
const _ = useTranslation();
return (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Code Highlighting')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span>{_('Enable Highlighting')}</span>
<input
type='checkbox'
className='toggle'
checked={codeHighlighting}
onChange={() => onToggle(!codeHighlighting)}
/>
</div>
<div className='config-item'>
<span>{_('Code Language')}</span>
<Select
value={codeLanguage}
onChange={(event) => onLanguageChange(event.target.value as CodeLanguage)}
options={CODE_LANGUAGES.map((lang) => ({
value: lang,
label: lang === 'auto-detect' ? _('Auto') : lang,
}))}
disabled={!codeHighlighting}
/>
</div>
</div>
</div>
</div>
<BoxedList title={_('Code Highlighting')} data-setting-id={dataSettingId}>
<SettingsSwitchRow
label={_('Enable Highlighting')}
checked={codeHighlighting}
onChange={() => onToggle(!codeHighlighting)}
/>
<SettingsRow label={_('Code Language')}>
<SettingsSelect
value={codeLanguage}
onChange={(event) => onLanguageChange(event.target.value as CodeLanguage)}
ariaLabel={_('Code Language')}
disabled={!codeHighlighting}
options={CODE_LANGUAGES.map((lang) => ({
value: lang,
label: lang === 'auto-detect' ? _('Auto') : lang,
}))}
/>
</SettingsRow>
</BoxedList>
);
};
@@ -1,11 +1,32 @@
import React, { useState, useEffect, useRef } from 'react';
import { SketchPicker, ColorResult } from 'react-color';
import { CgColorPicker } from 'react-icons/cg';
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
/**
* Fires when the user finishes choosing a color i.e. the hex input loses
* focus (compact mode) or the picker popup closes. Useful for auto-save
* flows where you want to pin the chosen color when the interaction
* settles, not on every onChange tick.
*/
onCommit?: () => void;
compact?: boolean;
/**
* Render only the color swatch as a circular button no hex input. Click
* the swatch to open the picker. Cleaner UX for casual users who don't
* care about the hex value.
*/
swatchOnly?: boolean;
/**
* In `swatchOnly` mode, render a small palette icon button immediately
* after the swatch. Both the swatch and the icon open the picker; the
* icon adds an explicit "click to change" affordance for users who might
* otherwise read the swatch as a passive preview.
*/
showPickerIcon?: boolean;
pickerPosition?: 'left' | 'center' | 'right';
};
@@ -13,7 +34,10 @@ const ColorInput: React.FC<ColorInputProps> = ({
label,
value,
onChange,
onCommit,
compact = false,
swatchOnly = false,
showPickerIcon = false,
pickerPosition = 'left',
}) => {
const [isOpen, setIsOpen] = useState(false);
@@ -23,6 +47,9 @@ const ColorInput: React.FC<ColorInputProps> = ({
function handleClickOutside(event: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setIsOpen(false);
// Picker close = "user is done choosing" — emit commit so callers
// can run auto-save logic (e.g. pin to a Quick Colors palette).
onCommit?.();
}
}
if (isOpen) {
@@ -31,7 +58,7 @@ const ColorInput: React.FC<ColorInputProps> = ({
document.removeEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
}, [isOpen, onCommit]);
const handlePickerChange = (colorResult: ColorResult) => {
onChange(colorResult.hex);
@@ -39,13 +66,52 @@ const ColorInput: React.FC<ColorInputProps> = ({
const getPickerPositionClass = () => {
if (pickerPosition === 'right') {
return 'right-0';
return 'end-0';
} else if (pickerPosition === 'center') {
return 'left-1/2 -translate-x-1/2';
return 'start-1/2 -translate-x-1/2';
}
return 'left-0';
return 'start-0';
};
if (swatchOnly) {
return (
<div className='relative flex items-center gap-1.5'>
<button
type='button'
onClick={() => setIsOpen(!isOpen)}
className='border-base-300 focus-visible:ring-base-content/20 focus-visible:ring-offset-base-100 h-7 w-7 rounded-full border-2 shadow-sm transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1'
style={{ backgroundColor: value }}
aria-label={label || 'Choose color'}
title={label || 'Choose color'}
/>
{showPickerIcon && (
<button
type='button'
onClick={() => setIsOpen(!isOpen)}
className='text-base-content/60 hover:bg-base-200 hover:text-base-content focus-visible:ring-base-content/15 inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2'
aria-label={label || 'Choose color'}
title={label || 'Choose color'}
>
<CgColorPicker className='h-5 w-5' />
</button>
)}
{isOpen && (
<div
ref={pickerRef}
className={`absolute top-full z-50 py-1 ${getPickerPositionClass()}`}
>
<SketchPicker
width='200px'
color={value}
onChange={handlePickerChange}
disableAlpha={true}
/>
</div>
)}
</div>
);
}
if (compact) {
return (
<div className='relative'>
@@ -54,6 +120,7 @@ const ColorInput: React.FC<ColorInputProps> = ({
value={value}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onCommit?.()}
onClick={() => setIsOpen(!isOpen)}
className='bg-base-100 text-base-content border-base-200/75 w-16 cursor-pointer rounded border px-1 py-0.5 text-center font-mono text-xs'
/>
@@ -80,7 +147,7 @@ const ColorInput: React.FC<ColorInputProps> = ({
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div className='flex items-center'>
<button
className='border-base-200/75 relative mr-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
className='border-base-200/75 relative me-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
style={{ backgroundColor: value }}
onClick={() => setIsOpen(!isOpen)}
/>
@@ -90,6 +157,7 @@ const ColorInput: React.FC<ColorInputProps> = ({
value={value}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onCommit?.()}
className='bg-base-100 text-base-content border-base-200/75 min-w-4 max-w-36 flex-1 rounded border p-1 font-mono text-sm'
/>
</div>
@@ -8,6 +8,7 @@ import {
UserHighlightColor,
} from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import { BoxedList, SettingLabel } from '../primitives';
import NumberInput from '../NumberInput';
import ColorInput from './ColorInput';
@@ -26,8 +27,8 @@ interface HighlightColorsEditorProps {
}
/**
* Popover that appears on click of a color circle, allowing the user to
* view and edit the label for that color.
* Floating popover with a single text input used to rename a highlight
* color label. Anchored above its relative-positioned parent.
*/
const LabelPopover: React.FC<{
label: string;
@@ -64,7 +65,7 @@ const LabelPopover: React.FC<{
return (
<div
ref={popoverRef}
className='bg-base-100 border-base-300 absolute -top-9 left-1/2 z-50 -translate-x-1/2 rounded-md border px-1 py-0.5 shadow-lg'
className='bg-base-100 border-base-300 absolute -top-9 start-1/2 z-50 -translate-x-1/2 rounded-md border px-1 py-0.5 shadow-lg'
>
<input
ref={inputRef}
@@ -77,38 +78,39 @@ const LabelPopover: React.FC<{
}}
placeholder={_('Name')}
maxLength={20}
className='bg-base-100 w-20 text-center text-xs outline-none'
className='bg-base-100 w-24 text-center text-xs outline-none'
/>
</div>
);
};
/**
* A color circle that shows label on hover and opens a label editor on click.
* Inline editable label below a color swatch. Click to open the
* <LabelPopover>. Shows the user-set label, or `placeholder` when empty
* (e.g. "Add label" hint).
*/
const ColorCircle: React.FC<{
hex: string;
label: string;
highlightPreviewStyle: React.CSSProperties;
onLabelCommit: (next: string) => void;
}> = ({ hex, label, highlightPreviewStyle, onLabelCommit }) => {
const EditableLabel: React.FC<{
value: string;
placeholder: string;
onCommit: (next: string) => void;
}> = ({ value, placeholder, onCommit }) => {
const [editing, setEditing] = useState(false);
return (
<div className='relative flex flex-col items-center'>
<div className='relative flex w-full justify-center'>
{editing && (
<LabelPopover label={label} onCommit={onLabelCommit} onClose={() => setEditing(false)} />
<LabelPopover label={value} onCommit={onCommit} onClose={() => setEditing(false)} />
)}
<div
className='border-base-300 h-8 w-8 cursor-pointer rounded-full border-2 shadow-sm'
title={label || undefined}
<button
type='button'
onClick={() => setEditing(true)}
className={clsx(
'hover:text-base-content max-w-full truncate text-xs hover:underline',
value ? 'text-base-content/75' : 'text-base-content/40',
)}
title={value || placeholder}
>
<div
className='h-full w-full rounded-full'
style={{ backgroundColor: hex, ...highlightPreviewStyle }}
/>
</div>
{value || placeholder}
</button>
</div>
);
};
@@ -129,13 +131,18 @@ const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
const _ = useTranslation();
const [newColor, setNewColor] = useState('#808080');
const highlightPreviewStyle: React.CSSProperties = {
opacity: highlightOpacity,
mixBlendMode:
'var(--overlayer-highlight-blend-mode, normal)' as React.CSSProperties['mixBlendMode'],
// Localized fallback names for the built-in highlight colors. Shown as
// greyed-out placeholders below each swatch; once the user enters their
// own label it replaces this hint.
const defaultColorNames: Record<DefaultHighlightColor, string> = {
red: _('Red'),
yellow: _('Yellow'),
green: _('Green'),
blue: _('Blue'),
violet: _('Violet'),
};
const handleDefaultHexChange = (color: DefaultHighlightColor, hex: string) => {
const handleDefaultHexChange = (color: HighlightColor, hex: string) => {
onCustomHighlightColorsChange({ ...customHighlightColors, [color]: hex });
};
@@ -183,116 +190,98 @@ const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
);
};
const isDuplicateNewColor = userHighlightColors.some(
(entry) => entry.hex === normalizeHex(newColor),
);
// Two-trigger UX inside each color cell: clicking the SWATCH opens the
// color picker (color edit), clicking the LABEL TEXT below opens the
// label popover (rename). Matches the TTS Highlighting swatch+commit
// pattern for color editing while preserving per-color labels.
return (
<div>
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
<div className='card border-base-200 bg-base-100 overflow-visible border shadow'>
<div className='grid grid-cols-3 gap-3 p-4 sm:grid-cols-5'>
{DEFAULT_HIGHLIGHT_COLORS.map((color, index, array) => {
const position = index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
return (
<div key={color} className='flex flex-col items-center gap-2'>
<ColorCircle
hex={customHighlightColors[color]!}
label={defaultHighlightLabels[color] ?? ''}
highlightPreviewStyle={highlightPreviewStyle}
onLabelCommit={(next) => handleDefaultLabelChange(color, next)}
/>
<ColorInput
label=''
value={customHighlightColors[color]!}
compact={true}
pickerPosition={position}
onChange={(value: string) => handleDefaultHexChange(color, value)}
/>
</div>
);
})}
</div>
<div className='border-base-200 border-t p-4'>
<div
className={clsx(
'flex items-center justify-between',
userHighlightColors.length > 0 && 'mb-4',
)}
>
<span className='font-normal'>
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
</span>
<div className='flex items-center gap-2'>
<div className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'>
<div
className='h-full w-full rounded-full'
style={{ backgroundColor: newColor, ...highlightPreviewStyle }}
/>
</div>
<BoxedList title={_('Highlight Colors')}>
<div className='grid grid-cols-3 gap-3 py-4 pe-4 sm:grid-cols-5'>
{DEFAULT_HIGHLIGHT_COLORS.map((color, index, array) => {
const position = index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
const userLabel = defaultHighlightLabels[color] ?? '';
return (
<div key={color} className='flex flex-col items-center gap-1.5'>
<ColorInput
label=''
value={newColor}
compact={true}
pickerPosition='right'
onChange={setNewColor}
label={_('Edit color')}
value={customHighlightColors[color]!}
swatchOnly
pickerPosition={position}
onChange={(value: string) => handleDefaultHexChange(color, value)}
/>
<EditableLabel
value={userLabel}
placeholder={defaultColorNames[color]}
onCommit={(next) => handleDefaultLabelChange(color, next)}
/>
<button
onClick={handleAddUserColor}
disabled={
isDuplicateNewColor || userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
}
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
>
<span className='text-xs'>{_('Add')}</span>
</button>
</div>
</div>
);
})}
</div>
{userHighlightColors.length > 0 && (
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{userHighlightColors.map(({ hex, label }, index) => (
<div key={index} className='group relative flex flex-col items-center gap-2'>
<ColorCircle
hex={hex}
label={label ?? ''}
highlightPreviewStyle={highlightPreviewStyle}
onLabelCommit={(next) => handleUserLabelChange(hex, next)}
/>
<ColorInput
label=''
value={hex}
compact={true}
pickerPosition={index === 0 ? 'left' : 'center'}
onChange={(value: string) => handleUserHexChange(hex, value)}
/>
<button
onClick={() => handleDeleteUserColor(hex)}
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<MdClose size={12} />
</button>
</div>
))}
</div>
<div className='py-4 pe-4'>
<div
className={clsx(
'flex items-center justify-between',
userHighlightColors.length > 0 && 'mb-4',
)}
</div>
<div className='border-base-200 border-t'>
<NumberInput
label={_('Opacity')}
value={highlightOpacity}
onChange={onOpacityChange}
disabled={isEink}
min={0}
max={1}
step={0.1}
>
<SettingLabel>
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
</SettingLabel>
{/* Swatch + picker icon matches TTS Highlighting's Color row.
Closing the picker fires onCommit, which auto-pins the color
to the user's palette. */}
<ColorInput
label={_('Add custom color')}
value={newColor}
swatchOnly
showPickerIcon
pickerPosition='right'
onChange={setNewColor}
onCommit={handleAddUserColor}
/>
</div>
{userHighlightColors.length > 0 && (
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{userHighlightColors.map(({ hex, label }, index) => (
<div key={index} className='group relative flex flex-col items-center gap-1.5'>
<ColorInput
label={_('Edit color')}
value={hex}
swatchOnly
pickerPosition={index === 0 ? 'left' : 'center'}
onChange={(value: string) => handleUserHexChange(hex, value)}
/>
<EditableLabel
value={label ?? ''}
placeholder={_('Add label')}
onCommit={(next) => handleUserLabelChange(hex, next)}
/>
<button
onClick={() => handleDeleteUserColor(hex)}
className='absolute -end-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<MdClose size={12} />
</button>
</div>
))}
</div>
)}
</div>
</div>
<NumberInput
label={_('Opacity')}
value={highlightOpacity}
onChange={onOpacityChange}
disabled={isEink}
min={0.1}
max={1}
step={0.1}
/>
</BoxedList>
);
};
@@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { ReadingRulerColor } from '@/types/book';
import { BoxedList, SettingsRow, SettingsSwitchRow } from '../primitives';
import NumberInput from '../NumberInput';
interface ReadingRulerSettingsProps {
@@ -12,6 +13,7 @@ interface ReadingRulerSettingsProps {
onLinesChange: (lines: number) => void;
onOpacityChange: (opacity: number) => void;
onColorChange: (color: ReadingRulerColor) => void;
'data-setting-id'?: string;
}
const RULER_COLORS: { value: ReadingRulerColor; className: string; hoverClassName: string }[] = [
@@ -31,59 +33,52 @@ const ReadingRulerSettings: React.FC<ReadingRulerSettingsProps> = ({
onLinesChange,
onOpacityChange,
onColorChange,
'data-setting-id': dataSettingId,
}) => {
const _ = useTranslation();
return (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Reading Ruler')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span>{_('Enable Reading Ruler')}</span>
<input
type='checkbox'
className='toggle'
checked={enabled}
onChange={() => onEnabledChange(!enabled)}
<BoxedList title={_('Reading Ruler')} data-setting-id={dataSettingId}>
<SettingsSwitchRow
label={_('Enable Reading Ruler')}
checked={enabled}
onChange={() => onEnabledChange(!enabled)}
/>
{/* NumberInput renders its own legacy `config-item` row h-14
matches the SettingsRow's min-h-14 so it visually rhymes with
the rows above and below. */}
<NumberInput
label={_('Lines to Highlight')}
value={lines}
onChange={onLinesChange}
disabled={!enabled}
min={1}
max={6}
step={1}
/>
<SettingsRow label={_('Ruler Color')}>
<div className='flex gap-2'>
{RULER_COLORS.map(({ value, className, hoverClassName }) => (
<button
key={value}
className={`btn btn-circle btn-sm ${className} ${hoverClassName} ${
color === value ? 'ring-base-content ring-2 ring-offset-1' : ''
} ${!enabled ? 'opacity-50' : ''}`}
onClick={() => enabled && onColorChange(value)}
/>
</div>
<NumberInput
label={_('Lines to Highlight')}
value={lines}
onChange={onLinesChange}
disabled={!enabled}
min={1}
max={6}
step={1}
/>
<div className='config-item'>
<span>{_('Ruler Color')}</span>
<div className='flex gap-2'>
{RULER_COLORS.map(({ value, className, hoverClassName }) => (
<button
key={value}
// disabled={!enabled}
className={`btn btn-circle btn-sm ${className} ${hoverClassName} ${
color === value ? 'ring-base-content ring-2 ring-offset-1' : ''
} ${!enabled ? 'opacity-50' : ''}`}
onClick={() => enabled && onColorChange(value)}
/>
))}
</div>
</div>
<NumberInput
label={_('Opacity')}
value={opacity}
onChange={onOpacityChange}
disabled={!enabled}
min={0.1}
max={0.9}
step={0.1}
/>
))}
</div>
</div>
</div>
</SettingsRow>
<NumberInput
label={_('Opacity')}
value={opacity}
onChange={onOpacityChange}
disabled={!enabled}
min={0.1}
max={0.9}
step={0.1}
/>
</BoxedList>
);
};
@@ -1,7 +1,7 @@
import React from 'react';
import { MdClose } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import Select from '@/components/Select';
import { BoxedList, SettingsRow, SettingsSelect } from '../primitives';
import ColorInput from './ColorInput';
export type TTSHighlightStyle =
@@ -38,8 +38,16 @@ const TTSHighlightStyleEditor: React.FC<TTSHighlightStyleEditorProps> = ({
{ color: '#FFA500', label: 'Orange' },
];
// Pin the current color to the user's Quick Colors palette. Validates
// the hex first so partial typing (e.g. "#7a") doesn't get saved, and
// skips colors that are already in the palette.
const isValidHex = (c: string) => /^#[0-9a-fA-F]{6}$/.test(c);
const handleAddCustomColor = () => {
if (!customColors.includes(color) && !defaultQuickColors.some((c) => c.color === color)) {
if (
isValidHex(color) &&
!customColors.includes(color) &&
!defaultQuickColors.some((c) => c.color === color)
) {
const updatedColors = [...customColors, color];
onCustomColorsChange(updatedColors);
}
@@ -51,96 +59,68 @@ const TTSHighlightStyleEditor: React.FC<TTSHighlightStyleEditorProps> = ({
};
return (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('TTS Highlighting')}</h2>
<div className='card border-base-200 bg-base-100 border p-4 shadow'>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Style')}</span>
<Select
value={style}
onChange={(e) => onStyleChange(e.target.value as TTSHighlightStyle)}
options={[
{ value: 'highlight', label: _('Highlighter') },
{ value: 'underline', label: _('Underline') },
{ value: 'strikethrough', label: _('Strikethrough') },
{ value: 'squiggly', label: _('Squiggly') },
{ value: 'outline', label: _('Outline') },
]}
/>
</div>
<BoxedList title={_('TTS Highlighting')}>
<SettingsRow label={_('Style')}>
<SettingsSelect
value={style}
onChange={(e) => onStyleChange(e.target.value as TTSHighlightStyle)}
ariaLabel={_('Style')}
options={[
{ value: 'highlight', label: _('Highlighter') },
{ value: 'underline', label: _('Underline') },
{ value: 'strikethrough', label: _('Strikethrough') },
{ value: 'squiggly', label: _('Squiggly') },
{ value: 'outline', label: _('Outline') },
]}
/>
</SettingsRow>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Color')}</span>
<div className='flex items-center gap-2'>
<div
className='border-base-300 pointer-events-none h-8 w-8 cursor-pointer rounded-full border-2 shadow-sm'
style={{ backgroundColor: color }}
/>
<ColorInput
label=''
value={color}
compact={true}
pickerPosition='center'
onChange={onColorChange}
<SettingsRow label={_('Color')}>
<ColorInput
label={_('Choose color')}
value={color}
swatchOnly
showPickerIcon
pickerPosition='right'
onChange={onColorChange}
onCommit={handleAddCustomColor}
/>
</SettingsRow>
<SettingsRow label={_('Quick Colors')}>
<div className='my-4 flex max-w-[65%] flex-wrap content-center items-center justify-end gap-2'>
{defaultQuickColors.map(({ color: quickColor }) => (
<button
key={quickColor}
onClick={() => onColorChange(quickColor)}
className={`border-base-300 h-7 w-7 rounded-full border-2 transition-transform hover:scale-110 ${
color === quickColor ? 'ring-2 ring-indigo-500 ring-offset-1' : ''
}`}
style={{ backgroundColor: quickColor }}
/>
))}
{customColors.map((customColor) => (
<div key={customColor} className='group relative'>
<button
onClick={() => onColorChange(customColor)}
className={`border-base-300 h-7 w-7 rounded-full border-2 transition-transform hover:scale-110 ${
color === customColor ? 'ring-2 ring-indigo-500 ring-offset-1' : ''
}`}
style={{ backgroundColor: customColor }}
/>
<button
onClick={handleAddCustomColor}
disabled={
customColors.includes(color) || defaultQuickColors.some((c) => c.color === color)
}
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent'
title={_('Save Current Color')}
onClick={() => handleDeleteCustomColor(customColor)}
className='absolute -end-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<span className='text-xs'>{_('Save')}</span>
<MdClose size={12} />
</button>
</div>
</div>
<div className='border-base-200 flex justify-between border-t pt-4'>
<div className='flex items-center'>
<span className='text-sm font-medium'>{_('Quick Colors')}</span>
</div>
<div className='flex max-w-[70%] flex-wrap justify-end gap-2'>
{defaultQuickColors.map(({ color: quickColor }) => (
<button
key={quickColor}
onClick={() => onColorChange(quickColor)}
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
color === quickColor
? 'ring-2 ring-indigo-500 ring-offset-2'
: 'border-base-300'
}`}
style={{ backgroundColor: quickColor }}
/>
))}
{customColors.map((customColor) => (
<div key={customColor} className='group relative'>
<button
onClick={() => onColorChange(customColor)}
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
color === customColor
? 'ring-2 ring-indigo-500 ring-offset-2'
: 'border-base-300'
}`}
style={{ backgroundColor: customColor }}
/>
<button
onClick={() => handleDeleteCustomColor(customColor)}
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<MdClose size={12} />
</button>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</SettingsRow>
</BoxedList>
);
};
@@ -5,6 +5,7 @@ import { PiPlus } from 'react-icons/pi';
import { Theme } from '@/styles/themes';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { SectionTitle } from '../primitives';
interface ThemeColorSelectorProps {
themes: Theme[];
@@ -29,7 +30,7 @@ const ThemeColorSelector: React.FC<ThemeColorSelectorProps> = ({
return (
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<SectionTitle className='mb-2'>{_('Theme Color')}</SectionTitle>
<div className='grid grid-cols-3 gap-4'>
{themes.map(({ name, label, colors, isCustomizale }) => (
<button
@@ -42,8 +43,13 @@ const ThemeColorSelector: React.FC<ThemeColorSelectorProps> = ({
}
e.stopPropagation();
}}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg px-2 py-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
// Selected card gets a 2px border in the card's OWN text color
// (`border-current`) — guaranteed contrast against the theme's
// background, light or dark. The transparent border on inactive
// cards reserves the same 2px so selecting/deselecting doesn't
// shift the grid.
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 px-2 py-4 shadow-md ${
themeColor === name ? 'border-current' : 'border-transparent'
}`}
style={{
backgroundColor: isDarkMode ? colors.dark['base-100'] : colors.light['base-100'],
@@ -3,6 +3,7 @@ import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
import { TbSunMoon } from 'react-icons/tb';
import { useTranslation } from '@/hooks/useTranslation';
import { useAtmosphereStore } from '@/store/atmosphereStore';
import { SettingLabel } from '../primitives';
interface ThemeModeSelectorProps {
themeMode: 'auto' | 'light' | 'dark';
@@ -37,8 +38,8 @@ const ThemeModeSelector: React.FC<ThemeModeSelectorProps> = ({ themeMode, onThem
};
return (
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex items-center justify-between px-4'>
<SettingLabel>{_('Theme Mode')}</SettingLabel>
<div className='flex gap-4'>
<button
title={_('Auto Mode')}
@@ -0,0 +1,171 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import { HardcoverClient, HardcoverSyncMapStore } from '@/services/hardcover';
import SubPageHeader from '../SubPageHeader';
import { SectionTitle, SettingLabel } from '../primitives';
interface HardcoverFormProps {
onBack: () => void;
}
const HardcoverForm: React.FC<HardcoverFormProps> = ({ onBack }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [accessToken, setAccessToken] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const isConfigured = !!settings.hardcover?.accessToken;
const handleConnect = async () => {
setIsConnecting(true);
try {
const appService = await envConfig.getAppService();
const mapStore = new HardcoverSyncMapStore(appService);
const client = new HardcoverClient({ accessToken }, mapStore);
const { valid, isNetworkError } = await client.validateToken();
if (valid) {
const newSettings = {
...settings,
hardcover: {
enabled: true,
accessToken,
lastSyncedAt: settings.hardcover?.lastSyncedAt ?? 0,
},
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
} else if (isNetworkError) {
eventDispatcher.dispatch('toast', {
message: _('Unable to connect to Hardcover. Please check your network connection.'),
type: 'error',
});
} else {
eventDispatcher.dispatch('toast', {
message: _('Invalid Hardcover API token'),
type: 'error',
});
}
} finally {
setIsConnecting(false);
setAccessToken('');
}
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
hardcover: { enabled: false, accessToken: '', lastSyncedAt: 0 },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
eventDispatcher.dispatch('toast', { message: _('Disconnected from Hardcover'), type: 'info' });
};
const handleToggleEnabled = async () => {
const newSettings = {
...settings,
hardcover: { ...settings.hardcover, enabled: !settings.hardcover?.enabled },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const lastSyncedAt = settings.hardcover?.lastSyncedAt ?? 0;
const lastSyncedLabel = lastSyncedAt ? new Date(lastSyncedAt).toLocaleString() : _('Never');
const description: string = isConfigured
? _('Connected to Hardcover. Last synced {{time}}.', { time: lastSyncedLabel })
: _('Connect your Hardcover account to sync reading progress and notes.') +
' ' +
_('Get your API token from hardcover.app → Settings → API.');
return (
<div className='w-full'>
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('Hardcover')}
description={description}
onBack={onBack}
/>
{isConfigured ? (
<div className='space-y-5'>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
<label className='flex min-h-14 items-center justify-between px-4'>
<SettingLabel>{_('Sync Enabled')}</SettingLabel>
<input
type='checkbox'
className='toggle'
checked={settings.hardcover?.enabled ?? false}
onChange={handleToggleEnabled}
/>
</label>
</div>
</div>
<div className='flex justify-end'>
<button
type='button'
onClick={handleDisconnect}
className={clsx(
'eink-bordered',
'h-10 rounded-lg px-4 text-sm font-medium',
'text-error hover:bg-error/10',
'transition-colors duration-150',
'focus-visible:ring-error/40 focus-visible:outline-none focus-visible:ring-2',
)}
>
{_('Disconnect')}
</button>
</div>
</div>
) : (
<div className='space-y-5'>
<div className='space-y-1.5'>
<SectionTitle as='label' htmlFor='hardcover-token' className='block'>
{_('API Token')}
</SectionTitle>
<input
id='hardcover-token'
type='password'
placeholder={_('Paste your Hardcover API token')}
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
spellCheck='false'
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
/>
</div>
<div className='flex justify-end'>
<button
type='button'
onClick={handleConnect}
disabled={isConnecting || !accessToken}
className={clsx(
'btn btn-primary',
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
isConnecting && 'opacity-60',
)}
>
{isConnecting ? (
<span className='loading loading-spinner loading-sm' />
) : (
_('Connect')
)}
</button>
</div>
</div>
)}
</div>
);
};
export default HardcoverForm;
@@ -0,0 +1,308 @@
import clsx from 'clsx';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { md5 } from 'js-md5';
import { type as osType } from '@tauri-apps/plugin-os';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import { KOSyncClient } from '@/services/sync/KOSyncClient';
import { KOSyncChecksumMethod, KOSyncStrategy } from '@/types/settings';
import { debounce } from '@/utils/debounce';
import { getOSPlatform } from '@/utils/misc';
import SubPageHeader from '../SubPageHeader';
import { SectionTitle, SettingLabel, SettingsSelect } from '../primitives';
interface KOSyncFormProps {
onBack: () => void;
}
const KOSyncForm: React.FC<KOSyncFormProps> = ({ onBack }) => {
const _ = useTranslation();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { envConfig, appService } = useEnv();
const [url, setUrl] = useState(settings.kosync.serverUrl || '');
const [username, setUsername] = useState(settings.kosync.username || '');
const [password, setPassword] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [deviceName, setDeviceName] = useState('');
const [osName, setOsName] = useState('');
useEffect(() => {
const formatOsName = (name: string): string => {
if (!name) return '';
if (name.toLowerCase() === 'macos') return 'macOS';
if (name.toLowerCase() === 'ios') return 'iOS';
return name.charAt(0).toUpperCase() + name.slice(1);
};
const getOsName = async () => {
let name = '';
if (appService?.appPlatform === 'tauri') {
name = await osType();
} else {
const platform = getOSPlatform();
if (platform !== 'unknown') {
name = platform;
}
}
setOsName(formatOsName(name));
};
getOsName();
}, [appService]);
useEffect(() => {
const defaultName = osName ? `Readest (${osName})` : 'Readest';
setDeviceName(settings.kosync.deviceName || defaultName);
}, [settings.kosync.deviceName, osName]);
const isConfigured = useMemo(() => !!settings.kosync.userkey, [settings.kosync.userkey]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSaveDeviceName = useCallback(
debounce((newDeviceName: string) => {
const newSettings = {
...settings,
kosync: { ...settings.kosync, deviceName: newDeviceName },
};
setSettings(newSettings);
saveSettings(envConfig, newSettings);
}, 500),
[settings, setSettings, saveSettings, envConfig],
);
const handleDeviceNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value;
setDeviceName(newName);
debouncedSaveDeviceName(newName);
};
const handleConnect = async () => {
setIsConnecting(true);
const config = {
...settings.kosync,
serverUrl: url,
username,
userkey: md5(password),
password,
deviceName,
enabled: true,
};
const client = new KOSyncClient(config);
const result = await client.connect(username, password);
if (result.success) {
const newSettings = { ...settings, kosync: config };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
} else {
eventDispatcher.dispatch('toast', {
message: `${_('Failed to connect')}: ${_(result.message || 'Connection error')}`,
type: 'error',
});
}
setIsConnecting(false);
setPassword('');
};
const handleDisconnect = async () => {
const kosync = { ...settings.kosync, userkey: '', enabled: false };
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
setUsername('');
eventDispatcher.dispatch('toast', { message: _('Disconnected'), type: 'info' });
};
const handleToggleEnabled = async () => {
const kosync = { ...settings.kosync, enabled: !settings.kosync.enabled };
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const kosync = { ...settings.kosync, strategy: e.target.value as KOSyncStrategy };
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const handleChecksumMethodChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const kosync = {
...settings.kosync,
checksumMethod: e.target.value as KOSyncChecksumMethod,
};
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const description: string = isConfigured
? _('Sync as {{userDisplayName}}', { userDisplayName: settings.kosync.username })
: _('Connect to your KOReader Sync server.');
return (
<div className='w-full'>
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('KOReader Sync')}
description={description}
onBack={onBack}
/>
{isConfigured ? (
<div className='space-y-5'>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
{/* Each row uses min-h-14 + items-center so toggle/select/input
rows render at a uniform height regardless of the embedded
control's intrinsic size. Selects and inputs are end-aligned
to match modern preferences-panel convention. */}
<label className='flex min-h-14 items-center justify-between px-4'>
<SettingLabel>{_('Sync Server Connected')}</SettingLabel>
<input
type='checkbox'
className='toggle'
checked={settings.kosync.enabled}
onChange={handleToggleEnabled}
/>
</label>
{/* SettingsSelect handles the chromeless treatment, the
custom MdArrowDropDown icon at the trailing edge (so the
chevron lands at the same X as the toggle's right edge),
and the focus/hover signal see DESIGN.md §5. */}
<div className='flex min-h-14 items-center justify-between gap-3 px-4'>
<SettingLabel>{_('Sync Strategy')}</SettingLabel>
<SettingsSelect
value={settings.kosync.strategy}
onChange={handleStrategyChange}
ariaLabel={_('Sync Strategy')}
options={[
{ value: 'prompt', label: _('Ask on conflict') },
{ value: 'silent', label: _('Always use latest') },
{ value: 'send', label: _('Send changes only') },
{ value: 'receive', label: _('Receive changes only') },
]}
/>
</div>
<div className='flex min-h-14 items-center justify-between gap-3 px-4'>
<SettingLabel>{_('Checksum Method')}</SettingLabel>
<SettingsSelect
value={settings.kosync.checksumMethod}
onChange={handleChecksumMethodChange}
ariaLabel={_('Checksum Method')}
options={[{ value: 'binary', label: _('File Content') }]}
/>
</div>
<div className='-me-2 flex min-h-14 items-center justify-between gap-3 px-4'>
<SettingLabel>{_('Device Name')}</SettingLabel>
<input
type='text'
placeholder={osName ? `Readest (${osName})` : 'Readest'}
className='input h-9 max-w-[60%] rounded-md !border-0 !bg-transparent !pe-3 !ps-2 text-end text-sm hover:!bg-transparent focus:!border-0 focus:!bg-transparent focus:!shadow-none focus:!outline-none focus:!ring-0'
value={deviceName}
onChange={handleDeviceNameChange}
/>
</div>
</div>
</div>
<div className='flex justify-end'>
<button
type='button'
onClick={handleDisconnect}
className={clsx(
'eink-bordered',
'h-10 rounded-lg px-4 text-sm font-medium',
'text-error hover:bg-error/10',
'transition-colors duration-150',
'focus-visible:ring-error/40 focus-visible:outline-none focus-visible:ring-2',
)}
>
{_('Disconnect')}
</button>
</div>
</div>
) : (
<div className='space-y-5'>
<form
className='space-y-4'
onSubmit={(e) => {
e.preventDefault();
handleConnect();
}}
>
<div className='space-y-1.5'>
<SectionTitle as='label' htmlFor='kosync-server-url' className='block'>
{_('Server URL')}
</SectionTitle>
<input
id='kosync-server-url'
type='text'
placeholder='https://koreader.sync.server'
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
spellCheck='false'
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className='space-y-1.5'>
<SectionTitle as='label' htmlFor='kosync-username' className='block'>
{_('Username')}
</SectionTitle>
<input
id='kosync-username'
type='text'
placeholder={_('Your Username')}
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
spellCheck='false'
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete='username'
/>
</div>
<div className='space-y-1.5'>
<SectionTitle as='label' htmlFor='kosync-password' className='block'>
{_('Password')}
</SectionTitle>
<input
id='kosync-password'
type='password'
placeholder={_('Your Password')}
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete='current-password'
/>
</div>
<div className='flex justify-end pt-1'>
<button
type='submit'
disabled={isConnecting || !url || !username || !password}
className={clsx(
'btn btn-primary',
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
isConnecting && 'opacity-60',
)}
>
{isConnecting ? (
<span className='loading loading-spinner loading-sm' />
) : (
_('Connect')
)}
</button>
</div>
</form>
</div>
)}
</div>
);
};
export default KOSyncForm;
@@ -0,0 +1,180 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import { ReadwiseClient } from '@/services/readwise';
import SubPageHeader from '../SubPageHeader';
import { SectionTitle, SettingLabel } from '../primitives';
interface ReadwiseFormProps {
onBack: () => void;
}
const ReadwiseForm: React.FC<ReadwiseFormProps> = ({ onBack }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [accessToken, setAccessToken] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const isConfigured = !!settings.readwise?.accessToken;
const handleConnect = async () => {
setIsConnecting(true);
try {
const client = new ReadwiseClient({ enabled: true, accessToken, lastSyncedAt: 0 });
const { valid, isNetworkError } = await client.validateToken();
if (valid) {
const newSettings = {
...settings,
readwise: {
enabled: true,
accessToken,
lastSyncedAt: settings.readwise?.lastSyncedAt ?? 0,
},
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
} else if (isNetworkError) {
eventDispatcher.dispatch('toast', {
message: _('Unable to connect to Readwise. Please check your network connection.'),
type: 'error',
});
} else {
eventDispatcher.dispatch('toast', {
message: _('Invalid Readwise access token'),
type: 'error',
});
}
} finally {
setIsConnecting(false);
setAccessToken('');
}
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
readwise: { enabled: false, accessToken: '', lastSyncedAt: 0 },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
eventDispatcher.dispatch('toast', { message: _('Disconnected from Readwise'), type: 'info' });
};
const handleToggleEnabled = async () => {
const newSettings = {
...settings,
readwise: { ...settings.readwise, enabled: !settings.readwise?.enabled },
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const lastSyncedAt = settings.readwise?.lastSyncedAt ?? 0;
const lastSyncedLabel = lastSyncedAt ? new Date(lastSyncedAt).toLocaleString() : _('Never');
const description: React.ReactNode = isConfigured ? (
_('Connected to Readwise. Last synced {{time}}.', { time: lastSyncedLabel })
) : (
<>
{_('Connect your Readwise account to sync highlights.')} {_('Get your access token at')}{' '}
<a
href='https://readwise.io/access_token'
target='_blank'
rel='noopener noreferrer'
className='link link-primary'
>
readwise.io/access_token
</a>
.
</>
);
return (
<div className='w-full'>
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('Readwise')}
description={description}
onBack={onBack}
/>
{isConfigured ? (
<div className='space-y-5'>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
<label className='flex min-h-14 items-center justify-between px-4'>
<SettingLabel>{_('Sync Enabled')}</SettingLabel>
<input
type='checkbox'
className='toggle'
checked={settings.readwise?.enabled ?? false}
onChange={handleToggleEnabled}
/>
</label>
</div>
</div>
<div className='flex justify-end'>
<button
type='button'
onClick={handleDisconnect}
className={clsx(
'eink-bordered',
'h-10 rounded-lg px-4 text-sm font-medium',
'text-error hover:bg-error/10',
'transition-colors duration-150',
'focus-visible:ring-error/40 focus-visible:outline-none focus-visible:ring-2',
)}
>
{_('Disconnect')}
</button>
</div>
</div>
) : (
<div className='space-y-5'>
<div className='space-y-1.5'>
<SectionTitle as='label' htmlFor='readwise-token' className='block'>
{_('Access Token')}
</SectionTitle>
<input
id='readwise-token'
type='password'
placeholder={_('Paste your Readwise access token')}
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
spellCheck='false'
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
/>
</div>
<div className='flex justify-end'>
<button
type='button'
onClick={handleConnect}
disabled={isConnecting || !accessToken}
className={clsx(
'btn btn-primary',
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
isConnecting && 'opacity-60',
)}
>
{isConnecting ? (
<span className='loading loading-spinner loading-sm' />
) : (
_('Connect')
)}
</button>
</div>
</div>
)}
</div>
);
};
export default ReadwiseForm;
@@ -0,0 +1,57 @@
import clsx from 'clsx';
import React from 'react';
import SectionTitle from './SectionTitle';
interface BoxedListProps {
/**
* Optional small-uppercase label above the boxed list (Adwaita
* AdwPreferencesGroup style). Style is fixed: caller passes the string.
*/
title?: string;
/**
* Optional one-line description rendered between the title and the list.
* Use sparingly most groups need just the label.
*/
description?: React.ReactNode;
/** Child rows — typically `<SettingsRow>` / `<SettingsSwitchRow>` / `<NavigationRow>`. */
children: React.ReactNode;
/** Outer wrapper className (spacing, data-setting-id ancestor, etc.). */
className?: string;
/** Inner card className (borders, bg, etc.). */
cardClassName?: string;
/** Inner wrapper className (padding, etc.). */
innerClassName?: string;
/** Forwarded to the outer wrapper for command-palette deep-linking. */
'data-setting-id'?: string;
}
/**
* Adwaita-style `AdwPreferencesGroup` container. Renders an optional small
* uppercase title + description, then the boxed-list card with `divide-y`
* rows inside. See DESIGN.md §5.
*/
const BoxedList: React.FC<BoxedListProps> = ({
title,
description,
children,
className,
cardClassName,
innerClassName,
'data-setting-id': dataSettingId,
}) => {
return (
<div className={clsx('w-full', className)} data-setting-id={dataSettingId}>
{title && <SectionTitle className='mb-2'>{title}</SectionTitle>}
{description && (
<p className='text-base-content/65 -mt-1 mb-2 text-[0.7em] leading-relaxed'>
{description}
</p>
)}
<div className={clsx('card eink-bordered border-base-200 bg-base-100 border', cardClassName)}>
<div className={clsx('divide-base-200 divide-y ps-4', innerClassName)}>{children}</div>
</div>
</div>
);
};
export default BoxedList;
@@ -0,0 +1,66 @@
import clsx from 'clsx';
import React from 'react';
import { MdChevronRight } from 'react-icons/md';
import SettingLabel from './SettingLabel';
interface NavigationRowProps {
/** Optional leading icon. Rendered inside a `bg-base-200` chip. */
icon?: React.ElementType;
/** Primary label. */
title: string;
/** Secondary line under the title (e.g. "Connected as user@host"). */
status?: string;
onClick: () => void;
disabled?: boolean;
'data-setting-id'?: string;
className?: string;
}
/**
* Boxed-list row that pushes the panel into a sub-page. Anatomy:
* prefix icon chip · title · status · trailing chevron-right. Hovers
* subtly; gets a focus-visible ring (since it IS a button, not a
* chromeless control like the boxed-list value cells).
*
* Used by `<IntegrationsPanel>` for KOSync/Readwise/Hardcover/OPDS rows;
* any panel that surfaces a "tap to drill into config" affordance should
* use this rather than rolling a custom button. See DESIGN.md §5.
*/
const NavigationRow: React.FC<NavigationRowProps> = ({
icon: Icon,
title,
status,
onClick,
disabled,
'data-setting-id': dataSettingId,
className,
}) => {
return (
<button
type='button'
onClick={onClick}
disabled={disabled}
data-setting-id={dataSettingId}
className={clsx(
'group flex w-full items-center gap-3 py-4 pe-4 text-left',
'transition-colors duration-150',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset',
disabled && 'cursor-not-allowed opacity-50',
className,
)}
>
{Icon && (
<span className='bg-base-200 text-base-content/70 group-hover:bg-base-300/70 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full transition-colors duration-150'>
<Icon className='h-5 w-5' />
</span>
)}
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<SettingLabel>{title}</SettingLabel>
{status && <span className='text-base-content/65 truncate text-[0.85em]'>{status}</span>}
</div>
<MdChevronRight className='text-base-content/50 h-5 w-5 flex-shrink-0' />
</button>
);
};
export default NavigationRow;
@@ -0,0 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import { isCaselessUILang } from '@/utils/misc';
type SectionTitleProps<T extends React.ElementType = 'h3'> = {
/** Element/component to render. Defaults to `<h3>` for section dividers;
* pass `'label'` for form-field titles, `'div'`/`'span'` if no semantic
* heading is wanted. */
as?: T;
children: React.ReactNode;
className?: string;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children' | 'className'>;
/**
* Canonical small-uppercase section title used across settings panels
* Theme Color, Background Image, Reading Sync, Content Sources, BoxedList
* group titles, and integration form field labels (Server URL, Username,
* etc.).
*
* Sizing rules (see DESIGN.md §5):
* - Latin / Cyrillic / Greek (cased scripts): `text-[0.85em]`. Casing
* plus tracking carries the visual emphasis.
* - Caseless scripts (CJK / Arabic / Hebrew / Indic / Thai / Tibetan):
* `text-[1em]`. `uppercase` is a no-op on Han/Hangul/Devanagari etc.,
* so the size has to do the work the casing can't.
*
* No margin is baked in callers control spacing via `className` (typical
* values: `mb-2` for top-of-section, no margin when the parent uses
* `space-y-*`).
*/
function SectionTitle<T extends React.ElementType = 'h3'>({
as,
children,
className,
...rest
}: SectionTitleProps<T>) {
const Tag = (as ?? 'h3') as React.ElementType;
return (
<Tag
className={clsx(
'ps-4',
isCaselessUILang()
? 'text-base-content/65 text-[0.8em] font-medium'
: 'text-base-content/65 text-[0.8em] font-medium',
className,
)}
{...rest}
>
{children}
</Tag>
);
}
export default SectionTitle;
@@ -0,0 +1,43 @@
import clsx from 'clsx';
import React from 'react';
type SettingLabelProps<T extends React.ElementType = 'span'> = {
/** Element/component to render. Defaults to `<span>`; pass `'label'` for
* form-field labels, `'div'` if needed. */
as?: T;
children: React.ReactNode;
className?: string;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children' | 'className'>;
/**
* Canonical primary label for a settings row / nav row / form field
* the per-item counterpart to `<SectionTitle>` (which labels the group).
*
* Sizing rules (see DESIGN.md §5):
* - Cased scripts (Latin / Cyrillic / Greek): adds `font-medium`.
* The medium weight gives the label a quiet hierarchy lift over body
* text without the loudness of `font-semibold`, matching Adwaita
* AdwActionRow titles.
* - Caseless scripts (CJK / Arabic / Hebrew / Indic / Thai / Tibetan):
* no weight class. Han / Hangul / Devanagari etc. don't bold cleanly
* at body size strokes thicken unevenly and rendering varies wildly
* across system fonts. Plain inherited weight reads cleaner.
*
* No font-size is set so the label inherits the `.settings-content`
* 14px-desktop / 16px-mobile cascade.
*/
function SettingLabel<T extends React.ElementType = 'span'>({
as,
children,
className,
...rest
}: SettingLabelProps<T>) {
const Tag = (as ?? 'span') as React.ElementType;
return (
<Tag className={clsx('text-base-content line-clamp-2', className)} {...rest}>
{children}
</Tag>
);
}
export default SettingLabel;
@@ -0,0 +1,39 @@
import clsx from 'clsx';
import React from 'react';
type SettingsInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> & {
/** Optional className merged in AFTER the chromeless base — use sparingly. */
className?: string;
};
/**
* Chromeless trailing-edge text input for use inside `<SettingsRow>` /
* `<BoxedList>`. Mirrors `<SettingsSelect>`'s no-chrome treatment:
* `!border-0 !bg-transparent` at rest, `bg-base-200/60` on hover/focus.
* Text end-aligns flush against the row's trailing edge (`!ps-2 !pe-0`)
* so the value's right edge lines up with toggles + select chevrons in
* adjacent rows. See DESIGN.md §5.
*/
const SettingsInput = React.forwardRef<HTMLInputElement, SettingsInputProps>(
({ className, ...props }, ref) => {
return (
<input
ref={ref}
{...props}
className={clsx(
'input settings-content h-9 max-w-[60%] rounded-md',
// `settings-content` re-applies the 14px-desktop / 16px-mobile
// font-size — <input> doesn't inherit it from the dialog wrapper.
'!border-0 !bg-transparent !pe-0 !ps-2 text-end',
'hover:!bg-base-200/60 focus:!bg-base-200/60',
'focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0',
className,
)}
/>
);
},
);
SettingsInput.displayName = 'SettingsInput';
export default SettingsInput;
@@ -0,0 +1,77 @@
import clsx from 'clsx';
import React from 'react';
import SettingLabel from './SettingLabel';
interface SettingsRowProps {
/** Primary label. ReactNode so callers can embed icons/badges. */
label: React.ReactNode;
/**
* Optional secondary line under the label (description / status hint).
* Keep it short settings rows aren't meant to host paragraphs.
*/
description?: React.ReactNode;
/** Trailing slot — typically the control (toggle, select, input, button). */
children?: React.ReactNode;
/**
* Render as `<label>` (default for toggle rows so clicking anywhere on
* the row forwards focus to the input). Set false for rows whose trailing
* control should NOT receive label-click forwarding (e.g., select rows
* where clicking the label area shouldn't reopen the dropdown).
*/
asLabel?: boolean;
/**
* Vertical alignment of label + trailing slot.
* - `'center'` (default): both vertically centered. Use for typical
* single-control rows (toggle, select, input).
* - `'start'`: both top-aligned with symmetric padding. Use when the
* trailing slot can wrap to multiple lines (e.g. a flex-wrap grid of
* swatches / chips) the label then aligns with the top row of
* wrapped content instead of floating between rows.
*/
align?: 'center' | 'start';
/** Greyed-out + non-interactive state. */
disabled?: boolean;
className?: string;
'data-setting-id'?: string;
}
/**
* Canonical boxed-list row at `min-h-14 items-center justify-between gap-3
* px-4`. Every row in a `<BoxedList>` should use this (or one of its
* specialized variants like `<SettingsSwitchRow>`) so the chassis stays
* uniform across the app. See DESIGN.md §5.
*/
const SettingsRow: React.FC<SettingsRowProps> = ({
label,
description,
children,
asLabel = false,
align = 'center',
disabled = false,
className,
'data-setting-id': dataSettingId,
}) => {
const Wrapper = asLabel ? 'label' : 'div';
return (
<Wrapper
data-setting-id={dataSettingId}
className={clsx(
'flex min-h-14 justify-between gap-3 pe-4',
align === 'start' ? 'items-start py-3.5' : 'items-center',
disabled && 'cursor-not-allowed opacity-50',
asLabel && !disabled && 'cursor-pointer',
className,
)}
>
<div className='flex min-w-0 flex-col'>
<SettingLabel>{label}</SettingLabel>
{description && (
<span className='text-base-content/65 text-[0.8em] leading-snug'>{description}</span>
)}
</div>
{children}
</Wrapper>
);
};
export default SettingsRow;
@@ -0,0 +1,63 @@
import React from 'react';
import { MdArrowDropDown } from 'react-icons/md';
interface SettingsSelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SettingsSelectProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: SettingsSelectOption[];
disabled?: boolean;
ariaLabel?: string;
}
/**
* Chromeless trailing-edge select for use inside `<SettingsRow>` /
* `<BoxedList>`. Renders the daisyui `<select>` element with all chrome
* suppressed (`!border-0 !bg-transparent !bg-none focus:!outline-none ...`)
* plus a real `<MdArrowDropDown>` icon at the cell's trailing edge so the
* chevron lands at the same X as toggles in adjacent rows.
*
* Hover / focus state is signaled by a wrapper bg-shift
* (`hover:bg-base-200/60 focus-within:bg-base-200/60`) no rings, per
* DESIGN.md §5's "Why no ring?" rule.
*/
const SettingsSelect: React.FC<SettingsSelectProps> = ({
value,
onChange,
options,
disabled,
ariaLabel,
}) => {
return (
<div className='flex max-w-[60%] items-center rounded-md focus-within:bg-transparent hover:bg-transparent'>
<select
value={value}
onChange={onChange}
onKeyDown={(e) => e.stopPropagation()}
disabled={disabled}
aria-label={ariaLabel}
className='select settings-content h-9 min-w-0 cursor-pointer !appearance-none truncate !border-0 !bg-transparent !bg-none !pe-1 !ps-2 text-end focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0'
style={{
textAlignLast: 'end',
}}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
<MdArrowDropDown
aria-hidden='true'
className='text-base-content/55 pointer-events-none h-5 w-5 flex-shrink-0'
/>
</div>
);
};
export default SettingsSelect;
@@ -0,0 +1,51 @@
import React from 'react';
import SettingsRow from './SettingsRow';
interface SettingsSwitchRowProps {
label: React.ReactNode;
description?: React.ReactNode;
checked: boolean;
onChange: () => void;
disabled?: boolean;
'data-setting-id'?: string;
}
/**
* Boxed-list row whose trailing control is a switch toggle. By far the most
* common settings row archetype wraps `<SettingsRow asLabel>` with a
* pre-baked daisyui `toggle` (default size) so callers don't repeat the
* input markup.
*
* Default-size `toggle` is intentional `toggle-sm` looks orphaned in a
* 56px settings row. Reserve smaller sizes (`toggle-sm`, `toggle-xs`) for
* inline switches inside cards / catalog rows where vertical space is
* tighter. See DESIGN.md §5.
*/
const SettingsSwitchRow: React.FC<SettingsSwitchRowProps> = ({
label,
description,
checked,
onChange,
disabled,
'data-setting-id': dataSettingId,
}) => {
return (
<SettingsRow
asLabel
label={label}
description={description}
disabled={disabled}
data-setting-id={dataSettingId}
>
<input
type='checkbox'
className='toggle'
checked={checked}
disabled={disabled}
onChange={onChange}
/>
</SettingsRow>
);
};
export default SettingsSwitchRow;
@@ -0,0 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import { MdInfoOutline } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
interface TipsProps {
/** Header label. Defaults to the translated "Tips". */
title?: string;
/** Bullet items — typically a list of `<li>` elements. */
children: React.ReactNode;
className?: string;
}
/**
* Quiet info callout used at the bottom of settings sub-pages to surface
* format requirements, usage hints, etc. Subtle `bg-base-200/40` surface,
* `MdInfoOutline` header icon, bulleted body. See the Dictionaries and
* Custom Fonts sub-pages for canonical use.
*/
const Tips: React.FC<TipsProps> = ({ title, children, className }) => {
const _ = useTranslation();
return (
<div className={clsx('bg-base-200/40 rounded-lg p-3', className)}>
{/* `text-[0.85em]` keeps the body proportional to the inherited
.settings-content size (12px desktop, 13.6px mobile) instead of
locking to Tailwind's hardcoded 12px text-xs. */}
<div className='text-base-content/70 text-[0.85em]'>
<div className='mb-1.5 flex items-center gap-1.5 font-medium'>
<MdInfoOutline className='h-4 w-4' />
{title ?? _('Tips')}
</div>
<ul className='space-y-0.5'>
{React.Children.map(children, (child, i) => {
const content =
React.isValidElement(child) &&
(child as React.ReactElement<{ children?: React.ReactNode }>).type === 'li'
? (child as React.ReactElement<{ children?: React.ReactNode }>).props.children
: child;
return (
<li key={i} className='flex items-start gap-2'>
<span className='flex h-[1.4em] w-4 flex-shrink-0 items-center justify-center'>
<span className='bg-base-content/70 h-1.5 w-1.5 rounded-full' />
</span>
<span className='min-w-0 flex-1'>{content}</span>
</li>
);
})}
</ul>
</div>
</div>
);
};
export default Tips;
@@ -0,0 +1,17 @@
/**
* Settings panel primitives the canonical building blocks for boxed-list
* settings UIs across all panels (Font, Layout, Color, Integrations, etc.).
* See DESIGN.md §5 for the rules each primitive embodies.
*
* Use these instead of inlining the chassis classnames; the moment a
* design rule changes, all callers update for free.
*/
export { default as BoxedList } from './BoxedList';
export { default as SettingsRow } from './SettingsRow';
export { default as SettingsSwitchRow } from './SettingsSwitchRow';
export { default as SettingsSelect } from './SettingsSelect';
export { default as SettingsInput } from './SettingsInput';
export { default as NavigationRow } from './NavigationRow';
export { default as SectionTitle } from './SectionTitle';
export { default as SettingLabel } from './SettingLabel';
export { default as Tips } from './Tips';
@@ -1,6 +1,6 @@
import { Fzf, FzfResultItem, byLengthAsc } from 'fzf';
import { SettingsPanelType } from '@/components/settings/SettingsDialog';
import { RiFontSize, RiDashboardLine, RiTranslate } from 'react-icons/ri';
import { RiFontSize, RiDashboardLine, RiTranslate, RiShareLine } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
@@ -154,6 +154,7 @@ const panelIcons: Record<SettingsPanelType, IconType> = {
TTS: PiSpeakerHigh,
Language: RiTranslate,
AI: PiRobot,
Integrations: RiShareLine,
Custom: IoAccessibilityOutline,
};
@@ -494,7 +494,8 @@ export const createMdictProvider = ({
// icons, etc.), then the dict's own loose CSS, then any
// MDD-resident stylesheets the MDX referenced via `<link>`.
// Cascade order matches authoring order.
const allStylesheets = [getDictStyles(), ...looseStylesheets, ...mddStylesheets];
const dictStyles = getDictStyles(ctx.bg ?? '', ctx.fg ?? '', !!ctx.isDarkMode);
const allStylesheets = [dictStyles, ...looseStylesheets, ...mddStylesheets];
for (const cssText of allStylesheets) {
if (!cssText) continue;
const style = document.createElement('style');
@@ -28,6 +28,10 @@ export interface DictionaryLookupContext {
* the current app theme. Optional defaults to light treatment.
*/
isDarkMode?: boolean;
/** Theme background color (e.g. `#ffffff`). Forwarded into shadow-scoped CSS. */
bg?: string;
/** Theme foreground color (e.g. `#1a1a1a`). Forwarded into shadow-scoped CSS. */
fg?: string;
}
export type DictionaryLookupOutcome =
+21 -1
View File
@@ -12,12 +12,28 @@ interface SettingsState {
isSettingsDialogOpen: boolean;
fontPanelView: FontPanelView;
activeSettingsItemId: string | null;
/**
* Deep-link target when set before opening the Settings dialog, the dialog
* mounts with this panel pre-selected (instead of the lastConfigPanel from
* localStorage). Cleared by the dialog after consumption.
*/
requestedPanel: string | null;
/**
* Optional sub-page hint paired with `requestedPanel`. When the requested
* panel renders nested sub-pages (e.g. Integrations KOSync / Readwise /
* Hardcover / OPDS), this string tells the panel which one to drill into.
* Cleared by the panel after consumption. Format is panel-specific
* Integrations recognises 'kosync' | 'readwise' | 'hardcover' | 'opds'.
*/
requestedSubPage: string | null;
setSettings: (settings: SystemSettings) => void;
saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void;
saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => Promise<void>;
setSettingsDialogBookKey: (bookKey: string) => void;
setSettingsDialogOpen: (open: boolean) => void;
setFontPanelView: (view: FontPanelView) => void;
setActiveSettingsItemId: (id: string | null) => void;
setRequestedPanel: (panel: string | null) => void;
setRequestedSubPage: (subPage: string | null) => void;
applyUILanguage: (uiLanguage?: string) => void;
}
@@ -28,6 +44,8 @@ export const useSettingsStore = create<SettingsState>((set) => ({
isSettingsDialogOpen: false,
fontPanelView: 'main-fonts',
activeSettingsItemId: null,
requestedPanel: null,
requestedSubPage: null,
setSettings: (settings) => set({ settings }),
saveSettings: async (envConfig: EnvConfigType, settings: SystemSettings) => {
const appService = await envConfig.getAppService();
@@ -37,6 +55,8 @@ export const useSettingsStore = create<SettingsState>((set) => ({
setSettingsDialogOpen: (open) => set({ isSettingsDialogOpen: open }),
setFontPanelView: (view) => set({ fontPanelView: view }),
setActiveSettingsItemId: (id) => set({ activeSettingsItemId: id }),
setRequestedPanel: (panel) => set({ requestedPanel: panel }),
setRequestedSubPage: (subPage) => set({ requestedSubPage: subPage }),
applyUILanguage: (uiLanguage?: string) => {
const locale = uiLanguage ? uiLanguage : navigator.language;
-3
View File
@@ -415,9 +415,6 @@ export interface BookConfig {
lastPushedAtNotes?: number;
foliateImportedAt?: number;
// Per-book switch for hardcover exports in reader menu.
hardcoverSyncEnabled?: boolean;
updatedAt: number;
}
+39
View File
@@ -13,6 +13,45 @@ export const isCJKLang = (lang: string | null | undefined): boolean => {
return ['zh', 'ja', 'ko', 'zho', 'jpn', 'kor'].includes(normalizedLang);
};
/**
* Languages whose primary script has no uppercase/lowercase distinction.
* Matters for UI rules that lean on the `uppercase` CSS property for visual
* emphasis those rules are no-ops here, so callers usually pair this with
* an alternate weight/size treatment (e.g. bigger font-size in section
* headers). Covers CJK, Arabic-script (Arabic, Persian), Hebrew, the major
* Indic scripts (Devanagari, Bengali, Tamil, Sinhala), Thai, and Tibetan.
*/
export const isCaselessLang = (lang: string | null | undefined): boolean => {
if (!lang) return false;
const normalizedLang = normalizedLangCode(lang);
return [
'zh',
'ja',
'ko', // CJK
'ar',
'fa', // Arabic script
'he', // Hebrew
'hi',
'bn',
'ta',
'si', // Indic scripts
'th', // Thai
'bo', // Tibetan
'zho',
'jpn',
'kor',
'ara',
'fas', // ISO-639-3 aliases
'heb',
'hin',
'ben',
'tam',
'sin',
'tha',
'bod',
].includes(normalizedLang);
};
const ZH_SCRIPTS_MAPPING: Record<string, string> = {
zh: 'zh-Hans',
'zh-cn': 'zh-Hans',
+16
View File
@@ -1,5 +1,6 @@
import { OsPlatform } from '@/types/system';
import { md5 } from 'js-md5';
import { isCaselessLang } from './lang';
export const uniqueId = () => Math.random().toString(36).substring(2, 9);
@@ -60,6 +61,21 @@ export const isCJKEnv = () => {
return isCJKLocale || isCJKUI;
};
/**
* True when the active UI language uses a script with no upper/lower case
* distinction (CJK, Arabic-script, Hebrew, major Indic scripts, Thai,
* Tibetan). Use this to opt out of UI rules that depend on the `uppercase`
* CSS property for emphasis those rules render as no-ops in caseless
* scripts and the affected text needs alternate visual weight (typically a
* larger font-size). Reads the active i18next locale from localStorage and
* falls back to navigator.language. Source list lives in `isCaselessLang`
* (utils/lang.ts).
*/
export const isCaselessUILang = () => {
const uiLanguage = localStorage?.getItem('i18nextLng') || navigator.language || '';
return isCaselessLang(uiLanguage);
};
export const getUserLocale = (lang: string): string | undefined => {
const languages =
navigator.languages && navigator.languages.length > 0
+10 -1
View File
@@ -539,7 +539,8 @@ export const getFootnoteStyles = () => `
* The seam exists so app-wide rules can be added in one place without
* touching the provider code. Currently it ships:
*/
export const getDictStyles = () => {
export const getDictStyles = (bg: string, fg: string, isDarkMode: boolean) => {
void fg;
return `
a:empty {
background-color: transparent;
@@ -548,6 +549,14 @@ export const getDictStyles = () => {
a img {
mix-blend-mode: multiply;
}
div[data-dict-kind="mdict"] .entry_name {
font-size: 1.2em;
margin-block-start: 0.5em;
margin-block-end: 0.5em;
}
div[data-dict-kind="mdict"] .juan_drop {
${isDarkMode ? `background-color: color-mix(in srgb, ${bg} 80%, #000);` : ''}
}
`;
};