forked from akai/readest
feat(share): time-limited share links with cfi-aware imports (#4037)
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.
Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
detection so logged-in recipients see "Add to my library" as the primary
action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
invariant that every files.file_key is prefixed with its row's user_id;
stats / purge / delete / download routes work unchanged. URL-encodes the
copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
token-bearing responses, atomic SQL increment for download_count via a
SECURITY DEFINER function so the public confirm beacon stays safe under
concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
never select the raw token, so accidental SELECT-* leakage on a public
route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
navigator.share with a clipboard fallback when no native share method
exists. Share-sheet dismissal no longer silently copies.
UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
+ toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.
Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
project's hand-curated en convention.
DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
## Feature Notes
|
||||
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
|
||||
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
|
||||
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
|
||||
|
||||
## Patterns
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
|
||||
@@ -39,3 +40,4 @@
|
||||
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
|
||||
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
|
||||
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
|
||||
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: en/translation.json holds ONLY plural variants and proper-noun overrides
|
||||
description: For non-plural strings, do NOT add to en/translation.json — the source-code key IS the en value via `defaultValue: key`. ONLY plural strings need explicit `_one`/`_other` entries in en, because i18next needs the forms to pick from.
|
||||
type: feedback
|
||||
originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f
|
||||
---
|
||||
|
||||
**Rule:** `public/locales/en/translation.json` is hand-curated and contains essentially two kinds of entries:
|
||||
|
||||
1. **Plural variants** (`<base>_one`, `<base>_other`, and locale-specific `_few`/`_many`/etc. as needed by CLDR). i18next MUST find these to know which form to render — without them, `count: 1` falls back to the bare key like `{{count}} days` and renders "1 days" instead of "1 day".
|
||||
2. **Proper-noun overrides** (e.g., font names like `LXGW WenKai GB Screen`) where the en value differs from the key.
|
||||
|
||||
Everything else — ordinary translatable strings like `Sign in to share books` — does NOT belong in en/translation.json. The translation hook calls `t(key, { defaultValue: key, ...options })`, so for any string not in en/translation.json, i18next renders the key itself. That's why a 51-key en file works for a codebase with thousands of `_()` callsites.
|
||||
|
||||
**Why en is hand-curated:** the project's `i18next-scanner.config.cjs` lists every locale EXCEPT `en` in its `lngs` array. The scanner generates `__STRING_NOT_TRANSLATED__` placeholders only for the listed locales; `en` is never touched.
|
||||
|
||||
**Workflow when adding `_(...)` calls:**
|
||||
|
||||
- **Non-plural string** (e.g. `_('Sign in to share books')`): add the `_(...)` call, run `pnpm run i18n:extract`, translate the new placeholders in non-en locales. **Do NOT touch `en/translation.json`** — the key itself is the en value.
|
||||
- **Plural string** (e.g. `_('{{count}} days', { count: n })`): same as above, PLUS hand-add `<base>_one` and `<base>_other` to `en/translation.json` (and `_few`/`_many`/etc. only if the source language ever needs them, which English doesn't). Convention from existing entries (e.g., `Are you sure to delete {{count}} selected book(s)?_one` → `Are you sure to delete {{count}} selected book?`): keep `{{count}}` interpolated even in `_one`, and swap any `(s)` placeholder to the proper singular/plural noun.
|
||||
|
||||
**Audit script:** walk `src/`, regex-match `_('...', { ..., count: ... })`, for each base key verify both `<base>_one` and `<base>_other` exist in `en/translation.json`. (See conversation history for an implementation.)
|
||||
|
||||
**Where this bit us:**
|
||||
|
||||
- Initial bug: `_('{{count}} days', { count: n })` rendered "1 days" because en had no `_one` form.
|
||||
- Audit found 16 missing en plural keys from earlier PRs (OPDS / TTS / dictionary import) silently rendering wrong.
|
||||
- Then overcorrected and added non-plural keys like `Sign in to share books` to en/translation.json — wrong, breaks the project's convention. en stays clean for non-plural strings.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: Share-a-Book Feature (in progress)
|
||||
description: Active implementation of /s/{token} share links + cherry-picks (CFI auto-include, 1-tap library import, branded OG images). All locked decisions and the plan file path.
|
||||
type: project
|
||||
originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f
|
||||
---
|
||||
Active feature on branch `dev` as of 2026-05-02. Plan file: `/Users/chrox/.claude/plans/ok-we-will-learn-cosmic-acorn.md` (always read for source of truth before continuing).
|
||||
|
||||
**Why:** complement just-shipped annotation deep-links (PRs #4018, #4019) with whole-book sharing. Cloud-sync infra exists; this layers public time-limited links on top.
|
||||
|
||||
**How to apply:** these decisions are locked across CEO + eng + design reviews. Do NOT re-litigate when implementing.
|
||||
|
||||
## Locked decisions (authoritative)
|
||||
|
||||
- **Universal 7-day cap** on share expiry, no tier differentiation, no "Never". All users pick `[1, 3, 7]` days. DMCA-risk reduction.
|
||||
- **App Router** for all share routes (mirrors `/o` precedent + recent Stripe / AI / IAP / OPDS). Pages API `storage/*` neighbors stay where they are.
|
||||
- **Single non-dynamic landing page** at `src/app/s/page.tsx` + rewrite `'/s/:token' → '/s?token=:token'` in `next.config.mjs`. Mirrors `/o`'s pattern exactly. Avoids `[token]` dynamic-segment trap under `output: 'export'`.
|
||||
- **R2 server-side byte-copy** for `/import` (recipient-side library import). NOT a reference. Preserves invariant that every `files` row's `file_key` starts with that row's `user_id` — keeps stats / purge / delete / download routes working unchanged.
|
||||
- **`token_hash` (sha256) in DB**, never the raw token. Raw token shown to user once at create.
|
||||
- **Live `(user_id, book_hash)` resolution** at every access (no FK to `files`). Re-uploads of the same hash follow the share automatically.
|
||||
- **GET `/download` is a 302 redirect with NO DB writes**. Count via separate `POST /download/confirm` so unfurlers / prefetchers can't inflate counts.
|
||||
- **Atomic SQL `download_count = download_count + 1`**, not read-modify-write.
|
||||
- **Per-user 50-share cap**, enforced at create-time.
|
||||
- **Auth detection on /s landing**: server-render via Supabase auth cookie in `app/s/layout.tsx`. No layout shift. Falls back to anonymous flow + post-hydration upgrade if SSR fails.
|
||||
- **Cover-less `og.png` fallback**: text-only display-type card. NO placeholder rectangle, NO procedural pattern.
|
||||
- **Universal Links / App Links**: handle `https://web.readest.com/s/...` in v1 via `useOpenShareLink`. Tauri's `applinks` config already covers the host.
|
||||
|
||||
## Cherry-picks accepted (CEO review SELECTIVE EXPANSION)
|
||||
|
||||
1. **Position-aware share** — every share initiated from the reader auto-includes `cfi`; recipient lands at sharer's paragraph. Toggle in dialog (default on, only visible when reader-context).
|
||||
3. **1-tap "Add to my library"** — logged-in recipient on `/s` gets primary action that calls `/import` (R2 byte-copy) and navigates to `/reader?ids=...&cfi=...`.
|
||||
4. **Branded OG image** — `/api/share/[token]/og.png` server-renders via `@vercel/og`. Spec includes anti-slop checklist.
|
||||
|
||||
Deferred to TODOS.md: QR code on landing, notify-on-download toggle.
|
||||
|
||||
## Open implementation questions (resolve mid-build)
|
||||
|
||||
1. Upload-confirmation: HEAD R2 in `/create`, or add `uploaded_at` column to `files`? — recommend HEAD.
|
||||
2. Migration directory: project has no `apps/readest-app/supabase/migrations/`. Confirm where SQL actually lands before writing the file.
|
||||
3. `@vercel/og` runtime compat with CloudFlare Workers (OpenNextJS). Verify before relying on it; fallback to Satori + sharp if incompatible.
|
||||
4. App Router route handlers under `src/app/api/share/...` should be silently dropped by `output: 'export'` in Next 16.2.3. Confirm during first Tauri build.
|
||||
|
||||
## Critical files for implementation
|
||||
|
||||
See "Critical Files (modify or create)" table in the plan. Key starting points:
|
||||
- `src/libs/storage-server.ts` — extract `getDownloadSignedUrl` from `pages/api/storage/download.ts`
|
||||
- `src/libs/share-server.ts` (new) — `generateShareToken()`, `hashShareToken(raw)`
|
||||
- `src/libs/share.ts` (new) — typed client used by dialog, manage section, deeplink hook, landing page
|
||||
- `src/utils/share.ts` (new) — `buildShareUrl(token)`, `parseShareDeepLink(url)`
|
||||
- Dialog reference: `src/components/Dialog.tsx`, `BookDetailModal.tsx`
|
||||
- Landing reference: `src/app/o/page.tsx` (lift `Card`, `BrandHeader`, `PageFooter` to `src/components/landing/`)
|
||||
- Deeplink hook reference: `src/hooks/useOpenAnnotationLink.ts`, `useOpenWithBooks.ts`
|
||||
- App-level upload entry: `appService.uploadBook(book)` at `src/services/appService.ts:269` (NOT `cloudService.uploadBook` — lower-level fn)
|
||||
@@ -84,6 +84,10 @@ const nextConfig = {
|
||||
source: '/o/book/:hash/annotation/:id',
|
||||
destination: '/o?book=:hash¬e=:id',
|
||||
},
|
||||
{
|
||||
source: '/s/:token',
|
||||
destination: '/s?token=:token',
|
||||
},
|
||||
];
|
||||
},
|
||||
async headers() {
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
{
|
||||
"/": "/o/*",
|
||||
"comment": "Matches annotation deeplinks"
|
||||
},
|
||||
{
|
||||
"/": "/s/*",
|
||||
"comment": "Matches book share links"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1327,5 +1327,84 @@
|
||||
"Open in Readest app": "فتح في تطبيق Readest",
|
||||
"Continue in browser": "متابعة في المتصفح",
|
||||
"Don't have Readest?": "ليس لديك Readest؟",
|
||||
"Book not in your library": "الكتاب غير موجود في مكتبتك"
|
||||
"Book not in your library": "الكتاب غير موجود في مكتبتك",
|
||||
"Share Book": "مشاركة الكتاب",
|
||||
"Could not create share link": "تعذر إنشاء رابط المشاركة",
|
||||
"Link copied": "تم نسخ الرابط",
|
||||
"Could not copy link": "تعذر نسخ الرابط",
|
||||
"Share revoked": "تم إلغاء المشاركة",
|
||||
"Could not revoke share": "تعذر إلغاء المشاركة",
|
||||
"Uploading book…": "جاري رفع الكتاب…",
|
||||
"Generating…": "جاري الإنشاء…",
|
||||
"Generate share link": "إنشاء رابط مشاركة",
|
||||
"Includes your current page": "يشمل صفحتك الحالية",
|
||||
"Share URL": "رابط المشاركة",
|
||||
"Copy link": "نسخ الرابط",
|
||||
"Copied": "تم النسخ",
|
||||
"Share via…": "مشاركة عبر…",
|
||||
"Expires {{date}}": "تنتهي في {{date}}",
|
||||
"Revoking…": "جاري الإلغاء…",
|
||||
"Revoke share": "إلغاء المشاركة",
|
||||
"Sign in to share books": "سجّل الدخول لمشاركة الكتب",
|
||||
"Expiring soon": "تنتهي صلاحيته قريبًا",
|
||||
"Missing share token": "رمز المشاركة مفقود",
|
||||
"Could not add to your library": "تعذرت الإضافة إلى مكتبتك",
|
||||
"This share link is no longer available": "لم يعد رابط المشاركة هذا متاحًا",
|
||||
"The original link may have expired or been revoked.": "ربما انتهت صلاحية الرابط الأصلي أو تم إلغاؤه.",
|
||||
"Get Readest": "احصل على Readest",
|
||||
"Loading shared book…": "جاري تحميل الكتاب المشترك…",
|
||||
"Adding…": "جاري الإضافة…",
|
||||
"Add to my library": "إضافة إلى مكتبتي",
|
||||
"Could not load your shares": "تعذر تحميل مشاركاتك",
|
||||
"Revoked": "ملغى",
|
||||
"Expired": "منتهي الصلاحية",
|
||||
"Shared books": "الكتب المشتركة",
|
||||
"You haven't shared any books yet": "لم تشارك أي كتب بعد",
|
||||
"Open a book and tap Share to send it to a friend.": "افتح كتابًا واضغط على مشاركة لإرساله إلى صديق.",
|
||||
"{{count}} active_zero": "لا توجد روابط نشطة",
|
||||
"{{count}} active_one": "رابط نشط واحد",
|
||||
"{{count}} active_two": "رابطان نشطان",
|
||||
"{{count}} active_few": "{{count}} روابط نشطة",
|
||||
"{{count}} active_many": "{{count}} رابطًا نشطًا",
|
||||
"{{count}} active_other": "{{count}} رابط نشط",
|
||||
"{{count}} downloads_zero": "لا توجد تنزيلات",
|
||||
"{{count}} downloads_one": "تنزيل واحد",
|
||||
"{{count}} downloads_two": "تنزيلان",
|
||||
"{{count}} downloads_few": "{{count}} تنزيلات",
|
||||
"{{count}} downloads_many": "{{count}} تنزيلًا",
|
||||
"{{count}} downloads_other": "{{count}} تنزيل",
|
||||
"starts at saved page": "يبدأ من الصفحة المحفوظة",
|
||||
"More actions": "المزيد من الإجراءات",
|
||||
"Loading…": "جاري التحميل…",
|
||||
"Load more": "تحميل المزيد",
|
||||
"Could not load shared book": "تعذر تحميل الكتاب المشترك",
|
||||
"The share link is missing required information.": "يفتقد رابط المشاركة معلومات مطلوبة.",
|
||||
"Please check your connection and try again.": "يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
|
||||
"Sign in to import shared books": "سجّل الدخول لاستيراد الكتب المشتركة",
|
||||
"Already in your library": "موجود بالفعل في مكتبتك",
|
||||
"Added to your library": "تمت الإضافة إلى مكتبتك",
|
||||
"Could not import shared book": "تعذر استيراد الكتاب المشترك",
|
||||
"Manage Shared Links": "إدارة روابط المشاركة",
|
||||
"Share current page": "مشاركة الصفحة الحالية",
|
||||
"Expires in": "تنتهي خلال",
|
||||
"{{count}} days_zero": "لا توجد أيام",
|
||||
"{{count}} days_one": "يوم واحد",
|
||||
"{{count}} days_two": "يومان",
|
||||
"{{count}} days_few": "{{count}} أيام",
|
||||
"{{count}} days_many": "{{count}} يومًا",
|
||||
"{{count}} days_other": "{{count}} يوم",
|
||||
"Expires in {{count}} days_zero": "تنتهي خلال أقل من يوم",
|
||||
"Expires in {{count}} days_one": "تنتهي خلال يوم واحد",
|
||||
"Expires in {{count}} days_two": "تنتهي خلال يومين",
|
||||
"Expires in {{count}} days_few": "تنتهي خلال {{count}} أيام",
|
||||
"Expires in {{count}} days_many": "تنتهي خلال {{count}} يومًا",
|
||||
"Expires in {{count}} days_other": "تنتهي خلال {{count}} يوم",
|
||||
"Expires in {{count}} hours_zero": "تنتهي خلال أقل من ساعة",
|
||||
"Expires in {{count}} hours_one": "تنتهي خلال ساعة واحدة",
|
||||
"Expires in {{count}} hours_two": "تنتهي خلال ساعتين",
|
||||
"Expires in {{count}} hours_few": "تنتهي خلال {{count}} ساعات",
|
||||
"Expires in {{count}} hours_many": "تنتهي خلال {{count}} ساعة",
|
||||
"Expires in {{count}} hours_other": "تنتهي خلال {{count}} ساعة",
|
||||
"Open in app": "فتح في التطبيق",
|
||||
"Shared with you": "تمت مشاركته معك"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Readest অ্যাপে খুলুন",
|
||||
"Continue in browser": "ব্রাউজারে চালিয়ে যান",
|
||||
"Don't have Readest?": "Readest নেই?",
|
||||
"Book not in your library": "এই বইটি আপনার লাইব্রেরিতে নেই"
|
||||
"Book not in your library": "এই বইটি আপনার লাইব্রেরিতে নেই",
|
||||
"Share Book": "বই শেয়ার করুন",
|
||||
"Could not create share link": "শেয়ার লিঙ্ক তৈরি করা যায়নি",
|
||||
"Link copied": "লিঙ্ক কপি হয়েছে",
|
||||
"Could not copy link": "লিঙ্ক কপি করা যায়নি",
|
||||
"Share revoked": "শেয়ার বাতিল হয়েছে",
|
||||
"Could not revoke share": "শেয়ার বাতিল করা যায়নি",
|
||||
"Uploading book…": "বই আপলোড হচ্ছে…",
|
||||
"Generating…": "তৈরি হচ্ছে…",
|
||||
"Generate share link": "শেয়ার লিঙ্ক তৈরি করুন",
|
||||
"Includes your current page": "আপনার বর্তমান পৃষ্ঠা সহ",
|
||||
"Share URL": "শেয়ার URL",
|
||||
"Copy link": "লিঙ্ক কপি করুন",
|
||||
"Copied": "কপি হয়েছে",
|
||||
"Share via…": "মাধ্যমে শেয়ার করুন…",
|
||||
"Expires {{date}}": "{{date}} এ মেয়াদ শেষ",
|
||||
"Revoking…": "বাতিল হচ্ছে…",
|
||||
"Revoke share": "শেয়ার বাতিল করুন",
|
||||
"Sign in to share books": "বই শেয়ার করতে সাইন ইন করুন",
|
||||
"Expiring soon": "শীঘ্রই মেয়াদ শেষ হবে",
|
||||
"Missing share token": "শেয়ার টোকেন অনুপস্থিত",
|
||||
"Could not add to your library": "আপনার লাইব্রেরিতে যোগ করা যায়নি",
|
||||
"This share link is no longer available": "এই শেয়ার লিঙ্কটি আর উপলব্ধ নেই",
|
||||
"The original link may have expired or been revoked.": "মূল লিঙ্কটির মেয়াদ শেষ হয়ে থাকতে পারে বা বাতিল হয়েছে।",
|
||||
"Get Readest": "Readest পান",
|
||||
"Loading shared book…": "শেয়ার করা বই লোড হচ্ছে…",
|
||||
"Adding…": "যোগ করা হচ্ছে…",
|
||||
"Add to my library": "আমার লাইব্রেরিতে যোগ করুন",
|
||||
"Could not load your shares": "আপনার শেয়ারগুলো লোড করা যায়নি",
|
||||
"Revoked": "বাতিল",
|
||||
"Expired": "মেয়াদোত্তীর্ণ",
|
||||
"Shared books": "শেয়ার করা বই",
|
||||
"You haven't shared any books yet": "আপনি এখনও কোনো বই শেয়ার করেননি",
|
||||
"Open a book and tap Share to send it to a friend.": "একটি বই খুলুন এবং বন্ধুকে পাঠাতে শেয়ার ট্যাপ করুন।",
|
||||
"{{count}} active_one": "{{count}}টি সক্রিয়",
|
||||
"{{count}} active_other": "{{count}}টি সক্রিয়",
|
||||
"{{count}} downloads_one": "{{count}}টি ডাউনলোড",
|
||||
"{{count}} downloads_other": "{{count}}টি ডাউনলোড",
|
||||
"starts at saved page": "সংরক্ষিত পৃষ্ঠা থেকে শুরু হয়",
|
||||
"More actions": "আরও ক্রিয়াকলাপ",
|
||||
"Loading…": "লোড হচ্ছে…",
|
||||
"Load more": "আরও লোড করুন",
|
||||
"Could not load shared book": "শেয়ার করা বই লোড করা যায়নি",
|
||||
"The share link is missing required information.": "শেয়ার লিঙ্কে প্রয়োজনীয় তথ্য নেই।",
|
||||
"Please check your connection and try again.": "অনুগ্রহ করে আপনার সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।",
|
||||
"Sign in to import shared books": "শেয়ার করা বই আমদানি করতে সাইন ইন করুন",
|
||||
"Already in your library": "ইতিমধ্যে আপনার লাইব্রেরিতে রয়েছে",
|
||||
"Added to your library": "আপনার লাইব্রেরিতে যোগ করা হয়েছে",
|
||||
"Could not import shared book": "শেয়ার করা বই আমদানি করা যায়নি",
|
||||
"Manage Shared Links": "শেয়ার লিঙ্ক পরিচালনা করুন",
|
||||
"Share current page": "বর্তমান পৃষ্ঠা শেয়ার করুন",
|
||||
"Expires in": "মেয়াদ শেষ হবে",
|
||||
"{{count}} days_one": "১ দিন",
|
||||
"{{count}} days_other": "{{count}} দিন",
|
||||
"Expires in {{count}} days_one": "১ দিনে মেয়াদ শেষ",
|
||||
"Expires in {{count}} days_other": "{{count}} দিনে মেয়াদ শেষ",
|
||||
"Expires in {{count}} hours_one": "১ ঘণ্টায় মেয়াদ শেষ",
|
||||
"Expires in {{count}} hours_other": "{{count}} ঘণ্টায় মেয়াদ শেষ",
|
||||
"Open in app": "অ্যাপে খুলুন",
|
||||
"Shared with you": "আপনার সাথে শেয়ার করা"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "Readest ཉེར་སྤྱོད་ནང་ཁ་ཕྱེ",
|
||||
"Continue in browser": "བརྡར་ཞིབ་ཆས་ནང་མུ་མཐུད",
|
||||
"Don't have Readest?": "Readest མེད་དམ།",
|
||||
"Book not in your library": "དེབ་འདི་ཁྱེད་ཀྱི་དཔེ་མཛོད་ནང་མི་འདུག"
|
||||
"Book not in your library": "དེབ་འདི་ཁྱེད་ཀྱི་དཔེ་མཛོད་ནང་མི་འདུག",
|
||||
"Share Book": "དཔེ་ཆ་མཉམ་སྤྱོད།",
|
||||
"Could not create share link": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་བཟོ་མ་ཐུབ།",
|
||||
"Link copied": "འབྲེལ་མཐུད་པར་སློག་གྲུབ།",
|
||||
"Could not copy link": "འབྲེལ་མཐུད་པར་སློག་མ་ཐུབ།",
|
||||
"Share revoked": "མཉམ་སྤྱོད་ཕྱིར་འཐེན།",
|
||||
"Could not revoke share": "མཉམ་སྤྱོད་ཕྱིར་འཐེན་མ་ཐུབ།",
|
||||
"Uploading book…": "དཔེ་ཆ་ཡར་སྐྱེལ་བཞིན་པ…",
|
||||
"Generating…": "བཟོ་བཞིན་པ…",
|
||||
"Generate share link": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་བཟོ།",
|
||||
"Includes your current page": "ཁྱེད་ཀྱི་ད་ལྟའི་ཤོག་ངོས་འདུས།",
|
||||
"Share URL": "མཉམ་སྤྱོད་གནས་སྡོད།",
|
||||
"Copy link": "འབྲེལ་མཐུད་པར་སློག",
|
||||
"Copied": "པར་སློག་གྲུབ།",
|
||||
"Share via…": "བརྒྱུད་ནས་མཉམ་སྤྱོད…",
|
||||
"Expires {{date}}": "{{date}} ལ་དུས་ཟད།",
|
||||
"Revoking…": "ཕྱིར་འཐེན་བཞིན་པ…",
|
||||
"Revoke share": "མཉམ་སྤྱོད་ཕྱིར་འཐེན།",
|
||||
"Sign in to share books": "དཔེ་ཆ་མཉམ་སྤྱོད་པར་ཐོ་འགོད་གནང་རོགས།",
|
||||
"Expiring soon": "མྱུར་དུ་ཟད་ངེས།",
|
||||
"Missing share token": "མཉམ་སྤྱོད་ཐམ་ག་མེད།",
|
||||
"Could not add to your library": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་སྣོན་མ་ཐུབ།",
|
||||
"This share link is no longer available": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་འདི་མི་འཐོབ།",
|
||||
"The original link may have expired or been revoked.": "གདོད་མའི་འབྲེལ་མཐུད་དུས་ཟད་པའམ་ཕྱིར་འཐེན་སྲིད།",
|
||||
"Get Readest": "Readest བཞེས་པ།",
|
||||
"Loading shared book…": "མཉམ་སྤྱོད་དཔེ་ཆ་མར་སྐྱེལ་བཞིན་པ…",
|
||||
"Adding…": "སྣོན་བཞིན་པ…",
|
||||
"Add to my library": "ངའི་དཔེ་མཛོད་ལ་སྣོན།",
|
||||
"Could not load your shares": "ཁྱེད་ཀྱི་མཉམ་སྤྱོད་མར་སྐྱེལ་མ་ཐུབ།",
|
||||
"Revoked": "ཕྱིར་འཐེན།",
|
||||
"Expired": "དུས་ཟད།",
|
||||
"Shared books": "མཉམ་སྤྱོད་དཔེ་ཆ།",
|
||||
"You haven't shared any books yet": "ཁྱོས་ད་དུང་དཔེ་ཆ་གང་ཡང་མཉམ་སྤྱོད་མ་བྱས།",
|
||||
"Open a book and tap Share to send it to a friend.": "དཔེ་ཆ་ཞིག་ཁ་ཕྱེ་ནས་གྲོགས་པོ་ལ་སྐུར་ཆེད་མཉམ་སྤྱོད་གཏུགས།",
|
||||
"{{count}} active_other": "{{count}} ལས་འཛིན་བཞིན་པ།",
|
||||
"{{count}} downloads_other": "{{count}} ཕབ་ལེན།",
|
||||
"starts at saved page": "ཉར་ཚགས་ཤོག་ངོས་ནས་འགོ་འཛུགས།",
|
||||
"More actions": "བྱ་འགུལ་མང་བ།",
|
||||
"Loading…": "མར་སྐྱེལ་བཞིན་པ…",
|
||||
"Load more": "མང་བ་མར་སྐྱེལ།",
|
||||
"Could not load shared book": "མཉམ་སྤྱོད་དཔེ་ཆ་མར་སྐྱེལ་མ་ཐུབ།",
|
||||
"The share link is missing required information.": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་ལ་ཆ་རྐྱེན་མེད།",
|
||||
"Please check your connection and try again.": "ཁྱེད་ཀྱི་འབྲེལ་མཐུད་ཞིབ་འཇུག་གནང་ནས་ཡང་བསྐྱར་ཐེངས་གཅིག་འབད་རོགས།",
|
||||
"Sign in to import shared books": "མཉམ་སྤྱོད་དཔེ་ཆ་འདྲེན་པར་ཐོ་འགོད་གནང་རོགས།",
|
||||
"Already in your library": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ནང་ཡོད་ཟིན་པ།",
|
||||
"Added to your library": "ཁྱེད་ཀྱི་དཔེ་མཛོད་ལ་སྣོན་ཟིན།",
|
||||
"Could not import shared book": "མཉམ་སྤྱོད་དཔེ་ཆ་འདྲེན་མ་ཐུབ།",
|
||||
"Manage Shared Links": "མཉམ་སྤྱོད་འབྲེལ་མཐུད་དོ་དམ།",
|
||||
"Share current page": "ད་ལྟའི་ཤོག་ངོས་མཉམ་སྤྱོད།",
|
||||
"Expires in": "དུས་ཟད།",
|
||||
"{{count}} days_other": "ཉིན་{{count}}",
|
||||
"Expires in {{count}} days_other": "ཉིན་{{count}}ནང་ཟད།",
|
||||
"Expires in {{count}} hours_other": "ཆུ་ཚོད་{{count}}ནང་ཟད།",
|
||||
"Open in app": "ཉེར་སྤྱོད་ནང་ཁ་ཕྱེ།",
|
||||
"Shared with you": "ཁྱེད་དང་མཉམ་སྤྱོད་བྱས།"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "In der Readest-App öffnen",
|
||||
"Continue in browser": "Im Browser fortfahren",
|
||||
"Don't have Readest?": "Du hast Readest noch nicht?",
|
||||
"Book not in your library": "Buch nicht in deiner Bibliothek"
|
||||
"Book not in your library": "Buch nicht in deiner Bibliothek",
|
||||
"Share Book": "Buch teilen",
|
||||
"Could not create share link": "Freigabelink konnte nicht erstellt werden",
|
||||
"Link copied": "Link kopiert",
|
||||
"Could not copy link": "Link konnte nicht kopiert werden",
|
||||
"Share revoked": "Freigabe widerrufen",
|
||||
"Could not revoke share": "Freigabe konnte nicht widerrufen werden",
|
||||
"Uploading book…": "Buch wird hochgeladen…",
|
||||
"Generating…": "Wird erstellt…",
|
||||
"Generate share link": "Freigabelink erstellen",
|
||||
"Includes your current page": "Enthält Ihre aktuelle Seite",
|
||||
"Share URL": "Freigabelink",
|
||||
"Copy link": "Link kopieren",
|
||||
"Copied": "Kopiert",
|
||||
"Share via…": "Teilen über…",
|
||||
"Expires {{date}}": "Läuft ab am {{date}}",
|
||||
"Revoking…": "Wird widerrufen…",
|
||||
"Revoke share": "Freigabe widerrufen",
|
||||
"Sign in to share books": "Melde dich an, um Bücher zu teilen",
|
||||
"Expiring soon": "Läuft bald ab",
|
||||
"Missing share token": "Freigabe-Token fehlt",
|
||||
"Could not add to your library": "Konnte nicht zu deiner Bibliothek hinzugefügt werden",
|
||||
"This share link is no longer available": "Dieser Freigabelink ist nicht mehr verfügbar",
|
||||
"The original link may have expired or been revoked.": "Der ursprüngliche Link ist möglicherweise abgelaufen oder wurde widerrufen.",
|
||||
"Get Readest": "Readest holen",
|
||||
"Loading shared book…": "Geteiltes Buch wird geladen…",
|
||||
"Adding…": "Wird hinzugefügt…",
|
||||
"Add to my library": "Zu meiner Bibliothek hinzufügen",
|
||||
"Could not load your shares": "Deine Freigaben konnten nicht geladen werden",
|
||||
"Revoked": "Widerrufen",
|
||||
"Expired": "Abgelaufen",
|
||||
"Shared books": "Geteilte Bücher",
|
||||
"You haven't shared any books yet": "Du hast noch keine Bücher geteilt",
|
||||
"Open a book and tap Share to send it to a friend.": "Öffne ein Buch und tippe auf „Teilen“, um es an einen Freund zu senden.",
|
||||
"{{count}} active_one": "{{count}} aktiv",
|
||||
"{{count}} active_other": "{{count}} aktiv",
|
||||
"{{count}} downloads_one": "{{count}} Download",
|
||||
"{{count}} downloads_other": "{{count}} Downloads",
|
||||
"starts at saved page": "beginnt auf gespeicherter Seite",
|
||||
"More actions": "Weitere Aktionen",
|
||||
"Loading…": "Wird geladen…",
|
||||
"Load more": "Mehr laden",
|
||||
"Could not load shared book": "Geteiltes Buch konnte nicht geladen werden",
|
||||
"The share link is missing required information.": "Dem Freigabelink fehlen erforderliche Informationen.",
|
||||
"Please check your connection and try again.": "Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
||||
"Sign in to import shared books": "Melde dich an, um geteilte Bücher zu importieren",
|
||||
"Already in your library": "Bereits in deiner Bibliothek",
|
||||
"Added to your library": "Zu deiner Bibliothek hinzugefügt",
|
||||
"Could not import shared book": "Geteiltes Buch konnte nicht importiert werden",
|
||||
"Manage Shared Links": "Freigabelinks verwalten",
|
||||
"Share current page": "Aktuelle Seite teilen",
|
||||
"Expires in": "Läuft ab in",
|
||||
"{{count}} days_one": "1 Tag",
|
||||
"{{count}} days_other": "{{count}} Tage",
|
||||
"Expires in {{count}} days_one": "Läuft in 1 Tag ab",
|
||||
"Expires in {{count}} days_other": "Läuft in {{count}} Tagen ab",
|
||||
"Expires in {{count}} hours_one": "Läuft in 1 Stunde ab",
|
||||
"Expires in {{count}} hours_other": "Läuft in {{count}} Stunden ab",
|
||||
"Open in app": "In der App öffnen",
|
||||
"Shared with you": "Mit dir geteilt"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Άνοιγμα στην εφαρμογή Readest",
|
||||
"Continue in browser": "Συνέχεια στο πρόγραμμα περιήγησης",
|
||||
"Don't have Readest?": "Δεν έχετε το Readest;",
|
||||
"Book not in your library": "Το βιβλίο δεν βρίσκεται στη βιβλιοθήκη σας"
|
||||
"Book not in your library": "Το βιβλίο δεν βρίσκεται στη βιβλιοθήκη σας",
|
||||
"Share Book": "Κοινή χρήση βιβλίου",
|
||||
"Could not create share link": "Δεν ήταν δυνατή η δημιουργία συνδέσμου κοινής χρήσης",
|
||||
"Link copied": "Ο σύνδεσμος αντιγράφηκε",
|
||||
"Could not copy link": "Δεν ήταν δυνατή η αντιγραφή του συνδέσμου",
|
||||
"Share revoked": "Η κοινή χρήση ανακλήθηκε",
|
||||
"Could not revoke share": "Δεν ήταν δυνατή η ανάκληση της κοινής χρήσης",
|
||||
"Uploading book…": "Μεταφόρτωση βιβλίου…",
|
||||
"Generating…": "Δημιουργία…",
|
||||
"Generate share link": "Δημιουργία συνδέσμου κοινής χρήσης",
|
||||
"Includes your current page": "Περιλαμβάνει την τρέχουσα σελίδα σας",
|
||||
"Share URL": "URL κοινής χρήσης",
|
||||
"Copy link": "Αντιγραφή συνδέσμου",
|
||||
"Copied": "Αντιγράφηκε",
|
||||
"Share via…": "Κοινή χρήση μέσω…",
|
||||
"Expires {{date}}": "Λήγει στις {{date}}",
|
||||
"Revoking…": "Ανάκληση…",
|
||||
"Revoke share": "Ανάκληση κοινής χρήσης",
|
||||
"Sign in to share books": "Συνδεθείτε για να μοιραστείτε βιβλία",
|
||||
"Expiring soon": "Λήγει σύντομα",
|
||||
"Missing share token": "Λείπει το διακριτικό κοινής χρήσης",
|
||||
"Could not add to your library": "Δεν ήταν δυνατή η προσθήκη στη βιβλιοθήκη σας",
|
||||
"This share link is no longer available": "Αυτός ο σύνδεσμος κοινής χρήσης δεν είναι πλέον διαθέσιμος",
|
||||
"The original link may have expired or been revoked.": "Ο αρχικός σύνδεσμος ενδέχεται να έχει λήξει ή να έχει ανακληθεί.",
|
||||
"Get Readest": "Αποκτήστε το Readest",
|
||||
"Loading shared book…": "Φόρτωση κοινόχρηστου βιβλίου…",
|
||||
"Adding…": "Προσθήκη…",
|
||||
"Add to my library": "Προσθήκη στη βιβλιοθήκη μου",
|
||||
"Could not load your shares": "Δεν ήταν δυνατή η φόρτωση των κοινοποιήσεών σας",
|
||||
"Revoked": "Ανακλήθηκε",
|
||||
"Expired": "Έληξε",
|
||||
"Shared books": "Κοινόχρηστα βιβλία",
|
||||
"You haven't shared any books yet": "Δεν έχετε μοιραστεί κανένα βιβλίο ακόμη",
|
||||
"Open a book and tap Share to send it to a friend.": "Ανοίξτε ένα βιβλίο και πατήστε «Κοινή χρήση» για να το στείλετε σε έναν φίλο.",
|
||||
"{{count}} active_one": "{{count}} ενεργό",
|
||||
"{{count}} active_other": "{{count}} ενεργά",
|
||||
"{{count}} downloads_one": "{{count}} λήψη",
|
||||
"{{count}} downloads_other": "{{count}} λήψεις",
|
||||
"starts at saved page": "ξεκινά από την αποθηκευμένη σελίδα",
|
||||
"More actions": "Περισσότερες ενέργειες",
|
||||
"Loading…": "Φόρτωση…",
|
||||
"Load more": "Φόρτωση περισσότερων",
|
||||
"Could not load shared book": "Δεν ήταν δυνατή η φόρτωση του κοινόχρηστου βιβλίου",
|
||||
"The share link is missing required information.": "Από τον σύνδεσμο κοινής χρήσης λείπουν απαιτούμενες πληροφορίες.",
|
||||
"Please check your connection and try again.": "Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.",
|
||||
"Sign in to import shared books": "Συνδεθείτε για να εισαγάγετε κοινόχρηστα βιβλία",
|
||||
"Already in your library": "Υπάρχει ήδη στη βιβλιοθήκη σας",
|
||||
"Added to your library": "Προστέθηκε στη βιβλιοθήκη σας",
|
||||
"Could not import shared book": "Δεν ήταν δυνατή η εισαγωγή του κοινόχρηστου βιβλίου",
|
||||
"Manage Shared Links": "Διαχείριση συνδέσμων κοινής χρήσης",
|
||||
"Share current page": "Κοινή χρήση τρέχουσας σελίδας",
|
||||
"Expires in": "Λήγει σε",
|
||||
"{{count}} days_one": "1 ημέρα",
|
||||
"{{count}} days_other": "{{count}} ημέρες",
|
||||
"Expires in {{count}} days_one": "Λήγει σε 1 ημέρα",
|
||||
"Expires in {{count}} days_other": "Λήγει σε {{count}} ημέρες",
|
||||
"Expires in {{count}} hours_one": "Λήγει σε 1 ώρα",
|
||||
"Expires in {{count}} hours_other": "Λήγει σε {{count}} ώρες",
|
||||
"Open in app": "Άνοιγμα στην εφαρμογή",
|
||||
"Shared with you": "Κοινοποιήθηκε σε εσάς"
|
||||
}
|
||||
|
||||
@@ -23,5 +23,31 @@
|
||||
"Set status for {{count}} book(s)_one": "Set status for {{count}} book",
|
||||
"Set status for {{count}} book(s)_other": "Set status for {{count}} books",
|
||||
"{{count}} book(s) synced_one": "{{count}} book synced",
|
||||
"{{count}} book(s) synced_other": "{{count}} books synced"
|
||||
"{{count}} book(s) synced_other": "{{count}} books synced",
|
||||
"{{count}} active_one": "{{count}} active",
|
||||
"{{count}} active_other": "{{count}} active",
|
||||
"{{count}} downloads_one": "{{count}} download",
|
||||
"{{count}} downloads_other": "{{count}} downloads",
|
||||
"{{count}} days_one": "{{count}} day",
|
||||
"{{count}} days_other": "{{count}} days",
|
||||
"Expires in {{count}} days_one": "Expires in {{count}} day",
|
||||
"Expires in {{count}} days_other": "Expires in {{count}} days",
|
||||
"Expires in {{count}} hours_one": "Expires in {{count}} hour",
|
||||
"Expires in {{count}} hours_other": "Expires in {{count}} hours",
|
||||
"Attempts: {{count}}_one": "Attempts: {{count}}",
|
||||
"Attempts: {{count}}_other": "Attempts: {{count}}",
|
||||
"Failed to sync {{count}} OPDS catalog(s)_one": "Failed to sync {{count}} OPDS catalog",
|
||||
"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}} books refreshed_one": "{{count}} book refreshed",
|
||||
"{{count}} books refreshed_other": "{{count}} books refreshed",
|
||||
"{{count}} failed_one": "{{count}} failed",
|
||||
"{{count}} failed_other": "{{count}} failed",
|
||||
"{{count}} items_one": "{{count}} item",
|
||||
"{{count}} items_other": "{{count}} items",
|
||||
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} new item downloaded from OPDS",
|
||||
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} new items downloaded from OPDS",
|
||||
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voice",
|
||||
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} voices"
|
||||
}
|
||||
|
||||
@@ -1273,5 +1273,69 @@
|
||||
"Open in Readest app": "Abrir en la app de Readest",
|
||||
"Continue in browser": "Continuar en el navegador",
|
||||
"Don't have Readest?": "¿No tienes Readest?",
|
||||
"Book not in your library": "El libro no está en tu biblioteca"
|
||||
"Book not in your library": "El libro no está en tu biblioteca",
|
||||
"Share Book": "Compartir libro",
|
||||
"Could not create share link": "No se pudo crear el enlace de compartir",
|
||||
"Link copied": "Enlace copiado",
|
||||
"Could not copy link": "No se pudo copiar el enlace",
|
||||
"Share revoked": "Compartir revocado",
|
||||
"Could not revoke share": "No se pudo revocar el compartir",
|
||||
"Uploading book…": "Subiendo libro…",
|
||||
"Generating…": "Generando…",
|
||||
"Generate share link": "Generar enlace de compartir",
|
||||
"Includes your current page": "Incluye tu página actual",
|
||||
"Share URL": "URL de compartir",
|
||||
"Copy link": "Copiar enlace",
|
||||
"Copied": "Copiado",
|
||||
"Share via…": "Compartir vía…",
|
||||
"Expires {{date}}": "Caduca el {{date}}",
|
||||
"Revoking…": "Revocando…",
|
||||
"Revoke share": "Revocar compartir",
|
||||
"Sign in to share books": "Inicia sesión para compartir libros",
|
||||
"Expiring soon": "Caduca pronto",
|
||||
"Missing share token": "Falta el token de compartir",
|
||||
"Could not add to your library": "No se pudo añadir a tu biblioteca",
|
||||
"This share link is no longer available": "Este enlace ya no está disponible",
|
||||
"The original link may have expired or been revoked.": "Es posible que el enlace original haya caducado o se haya revocado.",
|
||||
"Get Readest": "Obtener Readest",
|
||||
"Loading shared book…": "Cargando libro compartido…",
|
||||
"Adding…": "Añadiendo…",
|
||||
"Add to my library": "Añadir a mi biblioteca",
|
||||
"Could not load your shares": "No se pudieron cargar tus compartidos",
|
||||
"Revoked": "Revocado",
|
||||
"Expired": "Caducado",
|
||||
"Shared books": "Libros compartidos",
|
||||
"You haven't shared any books yet": "Aún no has compartido ningún libro",
|
||||
"Open a book and tap Share to send it to a friend.": "Abre un libro y pulsa Compartir para enviárselo a un amigo.",
|
||||
"{{count}} active_one": "{{count}} activo",
|
||||
"{{count}} active_many": "{{count}} activos",
|
||||
"{{count}} active_other": "{{count}} activos",
|
||||
"{{count}} downloads_one": "{{count}} descarga",
|
||||
"{{count}} downloads_many": "{{count}} descargas",
|
||||
"{{count}} downloads_other": "{{count}} descargas",
|
||||
"starts at saved page": "comienza en la página guardada",
|
||||
"More actions": "Más acciones",
|
||||
"Loading…": "Cargando…",
|
||||
"Load more": "Cargar más",
|
||||
"Could not load shared book": "No se pudo cargar el libro compartido",
|
||||
"The share link is missing required information.": "Al enlace de compartir le faltan datos necesarios.",
|
||||
"Please check your connection and try again.": "Comprueba tu conexión y vuelve a intentarlo.",
|
||||
"Sign in to import shared books": "Inicia sesión para importar libros compartidos",
|
||||
"Already in your library": "Ya está en tu biblioteca",
|
||||
"Added to your library": "Añadido a tu biblioteca",
|
||||
"Could not import shared book": "No se pudo importar el libro compartido",
|
||||
"Manage Shared Links": "Gestionar enlaces compartidos",
|
||||
"Share current page": "Compartir página actual",
|
||||
"Expires in": "Caduca en",
|
||||
"{{count}} days_one": "1 día",
|
||||
"{{count}} days_many": "{{count}} días",
|
||||
"{{count}} days_other": "{{count}} días",
|
||||
"Expires in {{count}} days_one": "Caduca en 1 día",
|
||||
"Expires in {{count}} days_many": "Caduca en {{count}} días",
|
||||
"Expires in {{count}} days_other": "Caduca en {{count}} días",
|
||||
"Expires in {{count}} hours_one": "Caduca en 1 hora",
|
||||
"Expires in {{count}} hours_many": "Caduca en {{count}} horas",
|
||||
"Expires in {{count}} hours_other": "Caduca en {{count}} horas",
|
||||
"Open in app": "Abrir en la app",
|
||||
"Shared with you": "Compartido contigo"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "باز کردن در برنامهٔ Readest",
|
||||
"Continue in browser": "ادامه در مرورگر",
|
||||
"Don't have Readest?": "Readest ندارید؟",
|
||||
"Book not in your library": "این کتاب در کتابخانهٔ شما نیست"
|
||||
"Book not in your library": "این کتاب در کتابخانهٔ شما نیست",
|
||||
"Share Book": "اشتراکگذاری کتاب",
|
||||
"Could not create share link": "ایجاد لینک اشتراکگذاری امکانپذیر نبود",
|
||||
"Link copied": "لینک کپی شد",
|
||||
"Could not copy link": "کپی کردن لینک امکانپذیر نبود",
|
||||
"Share revoked": "اشتراکگذاری لغو شد",
|
||||
"Could not revoke share": "لغو اشتراکگذاری امکانپذیر نبود",
|
||||
"Uploading book…": "در حال آپلود کتاب…",
|
||||
"Generating…": "در حال ایجاد…",
|
||||
"Generate share link": "ایجاد لینک اشتراکگذاری",
|
||||
"Includes your current page": "شامل صفحه فعلی شما",
|
||||
"Share URL": "آدرس اشتراکگذاری",
|
||||
"Copy link": "کپی لینک",
|
||||
"Copied": "کپی شد",
|
||||
"Share via…": "اشتراکگذاری از طریق…",
|
||||
"Expires {{date}}": "منقضی میشود در {{date}}",
|
||||
"Revoking…": "در حال لغو…",
|
||||
"Revoke share": "لغو اشتراکگذاری",
|
||||
"Sign in to share books": "برای اشتراکگذاری کتابها وارد شوید",
|
||||
"Expiring soon": "به زودی منقضی میشود",
|
||||
"Missing share token": "توکن اشتراکگذاری وجود ندارد",
|
||||
"Could not add to your library": "افزودن به کتابخانه شما امکانپذیر نبود",
|
||||
"This share link is no longer available": "این لینک اشتراکگذاری دیگر در دسترس نیست",
|
||||
"The original link may have expired or been revoked.": "لینک اصلی ممکن است منقضی شده یا لغو شده باشد.",
|
||||
"Get Readest": "دریافت Readest",
|
||||
"Loading shared book…": "در حال بارگذاری کتاب اشتراکگذاری شده…",
|
||||
"Adding…": "در حال افزودن…",
|
||||
"Add to my library": "افزودن به کتابخانه من",
|
||||
"Could not load your shares": "بارگذاری اشتراکگذاریهای شما امکانپذیر نبود",
|
||||
"Revoked": "لغو شده",
|
||||
"Expired": "منقضی شده",
|
||||
"Shared books": "کتابهای اشتراکگذاری شده",
|
||||
"You haven't shared any books yet": "هنوز کتابی به اشتراک نگذاشتهاید",
|
||||
"Open a book and tap Share to send it to a friend.": "یک کتاب باز کنید و روی اشتراکگذاری ضربه بزنید تا برای دوستتان بفرستید.",
|
||||
"{{count}} active_one": "{{count}} فعال",
|
||||
"{{count}} active_other": "{{count}} فعال",
|
||||
"{{count}} downloads_one": "{{count}} دانلود",
|
||||
"{{count}} downloads_other": "{{count}} دانلود",
|
||||
"starts at saved page": "از صفحه ذخیرهشده شروع میشود",
|
||||
"More actions": "اقدامات بیشتر",
|
||||
"Loading…": "در حال بارگذاری…",
|
||||
"Load more": "بارگذاری بیشتر",
|
||||
"Could not load shared book": "بارگذاری کتاب اشتراکگذاری شده امکانپذیر نبود",
|
||||
"The share link is missing required information.": "لینک اشتراکگذاری اطلاعات لازم را ندارد.",
|
||||
"Please check your connection and try again.": "لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید.",
|
||||
"Sign in to import shared books": "برای وارد کردن کتابهای اشتراکگذاری شده وارد شوید",
|
||||
"Already in your library": "از قبل در کتابخانه شما موجود است",
|
||||
"Added to your library": "به کتابخانه شما افزوده شد",
|
||||
"Could not import shared book": "وارد کردن کتاب اشتراکگذاری شده امکانپذیر نبود",
|
||||
"Manage Shared Links": "مدیریت لینکهای اشتراکگذاری",
|
||||
"Share current page": "اشتراک صفحه فعلی",
|
||||
"Expires in": "منقضی میشود در",
|
||||
"{{count}} days_one": "۱ روز",
|
||||
"{{count}} days_other": "{{count}} روز",
|
||||
"Expires in {{count}} days_one": "در ۱ روز منقضی میشود",
|
||||
"Expires in {{count}} days_other": "در {{count}} روز منقضی میشود",
|
||||
"Expires in {{count}} hours_one": "در ۱ ساعت منقضی میشود",
|
||||
"Expires in {{count}} hours_other": "در {{count}} ساعت منقضی میشود",
|
||||
"Open in app": "باز کردن در برنامه",
|
||||
"Shared with you": "با شما به اشتراک گذاشته شده"
|
||||
}
|
||||
|
||||
@@ -1273,5 +1273,69 @@
|
||||
"Open in Readest app": "Ouvrir dans l'application Readest",
|
||||
"Continue in browser": "Continuer dans le navigateur",
|
||||
"Don't have Readest?": "Vous n'avez pas Readest ?",
|
||||
"Book not in your library": "Livre absent de votre bibliothèque"
|
||||
"Book not in your library": "Livre absent de votre bibliothèque",
|
||||
"Share Book": "Partager le livre",
|
||||
"Could not create share link": "Impossible de créer le lien de partage",
|
||||
"Link copied": "Lien copié",
|
||||
"Could not copy link": "Impossible de copier le lien",
|
||||
"Share revoked": "Partage révoqué",
|
||||
"Could not revoke share": "Impossible de révoquer le partage",
|
||||
"Uploading book…": "Téléchargement du livre…",
|
||||
"Generating…": "Génération…",
|
||||
"Generate share link": "Créer un lien de partage",
|
||||
"Includes your current page": "Inclut votre page actuelle",
|
||||
"Share URL": "URL de partage",
|
||||
"Copy link": "Copier le lien",
|
||||
"Copied": "Copié",
|
||||
"Share via…": "Partager via…",
|
||||
"Expires {{date}}": "Expire le {{date}}",
|
||||
"Revoking…": "Révocation…",
|
||||
"Revoke share": "Révoquer le partage",
|
||||
"Sign in to share books": "Connectez-vous pour partager des livres",
|
||||
"Expiring soon": "Expire bientôt",
|
||||
"Missing share token": "Jeton de partage manquant",
|
||||
"Could not add to your library": "Impossible d'ajouter à votre bibliothèque",
|
||||
"This share link is no longer available": "Ce lien de partage n'est plus disponible",
|
||||
"The original link may have expired or been revoked.": "Le lien original a peut-être expiré ou été révoqué.",
|
||||
"Get Readest": "Obtenir Readest",
|
||||
"Loading shared book…": "Chargement du livre partagé…",
|
||||
"Adding…": "Ajout…",
|
||||
"Add to my library": "Ajouter à ma bibliothèque",
|
||||
"Could not load your shares": "Impossible de charger vos partages",
|
||||
"Revoked": "Révoqué",
|
||||
"Expired": "Expiré",
|
||||
"Shared books": "Livres partagés",
|
||||
"You haven't shared any books yet": "Vous n’avez encore partagé aucun livre",
|
||||
"Open a book and tap Share to send it to a friend.": "Ouvrez un livre et appuyez sur Partager pour l’envoyer à un ami.",
|
||||
"{{count}} active_one": "{{count}} actif",
|
||||
"{{count}} active_many": "{{count}} actifs",
|
||||
"{{count}} active_other": "{{count}} actifs",
|
||||
"{{count}} downloads_one": "{{count}} téléchargement",
|
||||
"{{count}} downloads_many": "{{count}} téléchargements",
|
||||
"{{count}} downloads_other": "{{count}} téléchargements",
|
||||
"starts at saved page": "commence à la page enregistrée",
|
||||
"More actions": "Plus d’actions",
|
||||
"Loading…": "Chargement…",
|
||||
"Load more": "Charger plus",
|
||||
"Could not load shared book": "Impossible de charger le livre partagé",
|
||||
"The share link is missing required information.": "Il manque des informations nécessaires au lien de partage.",
|
||||
"Please check your connection and try again.": "Vérifiez votre connexion et réessayez.",
|
||||
"Sign in to import shared books": "Connectez-vous pour importer des livres partagés",
|
||||
"Already in your library": "Déjà dans votre bibliothèque",
|
||||
"Added to your library": "Ajouté à votre bibliothèque",
|
||||
"Could not import shared book": "Impossible d'importer le livre partagé",
|
||||
"Manage Shared Links": "Gérer les liens de partage",
|
||||
"Share current page": "Partager la page actuelle",
|
||||
"Expires in": "Expire dans",
|
||||
"{{count}} days_one": "1 jour",
|
||||
"{{count}} days_many": "{{count}} jours",
|
||||
"{{count}} days_other": "{{count}} jours",
|
||||
"Expires in {{count}} days_one": "Expire dans 1 jour",
|
||||
"Expires in {{count}} days_many": "Expire dans {{count}} jours",
|
||||
"Expires in {{count}} days_other": "Expire dans {{count}} jours",
|
||||
"Expires in {{count}} hours_one": "Expire dans 1 heure",
|
||||
"Expires in {{count}} hours_many": "Expire dans {{count}} heures",
|
||||
"Expires in {{count}} hours_other": "Expire dans {{count}} heures",
|
||||
"Open in app": "Ouvrir dans l'app",
|
||||
"Shared with you": "Partagé avec vous"
|
||||
}
|
||||
|
||||
@@ -1273,5 +1273,69 @@
|
||||
"Open in Readest app": "פתיחה באפליקציית Readest",
|
||||
"Continue in browser": "המשך בדפדפן",
|
||||
"Don't have Readest?": "אין לך את Readest?",
|
||||
"Book not in your library": "הספר אינו בספרייה שלך"
|
||||
"Book not in your library": "הספר אינו בספרייה שלך",
|
||||
"Share Book": "שיתוף ספר",
|
||||
"Could not create share link": "לא ניתן היה ליצור קישור שיתוף",
|
||||
"Link copied": "הקישור הועתק",
|
||||
"Could not copy link": "לא ניתן היה להעתיק את הקישור",
|
||||
"Share revoked": "השיתוף בוטל",
|
||||
"Could not revoke share": "לא ניתן היה לבטל את השיתוף",
|
||||
"Uploading book…": "מעלה ספר…",
|
||||
"Generating…": "יוצר…",
|
||||
"Generate share link": "צור קישור שיתוף",
|
||||
"Includes your current page": "כולל את העמוד הנוכחי שלך",
|
||||
"Share URL": "כתובת שיתוף",
|
||||
"Copy link": "העתק קישור",
|
||||
"Copied": "הועתק",
|
||||
"Share via…": "שתף באמצעות…",
|
||||
"Expires {{date}}": "יפוג ב-{{date}}",
|
||||
"Revoking…": "מבטל…",
|
||||
"Revoke share": "בטל שיתוף",
|
||||
"Sign in to share books": "היכנס כדי לשתף ספרים",
|
||||
"Expiring soon": "יפוג בקרוב",
|
||||
"Missing share token": "אסימון שיתוף חסר",
|
||||
"Could not add to your library": "לא ניתן היה להוסיף לספרייה שלך",
|
||||
"This share link is no longer available": "קישור השיתוף הזה כבר אינו זמין",
|
||||
"The original link may have expired or been revoked.": "הקישור המקורי אולי פג תוקפו או בוטל.",
|
||||
"Get Readest": "הורד את Readest",
|
||||
"Loading shared book…": "טוען ספר משותף…",
|
||||
"Adding…": "מוסיף…",
|
||||
"Add to my library": "הוסף לספרייה שלי",
|
||||
"Could not load your shares": "לא ניתן היה לטעון את השיתופים שלך",
|
||||
"Revoked": "בוטל",
|
||||
"Expired": "פג תוקף",
|
||||
"Shared books": "ספרים משותפים",
|
||||
"You haven't shared any books yet": "עדיין לא שיתפת ספרים",
|
||||
"Open a book and tap Share to send it to a friend.": "פתח ספר והקש על שיתוף כדי לשלוח אותו לחבר.",
|
||||
"{{count}} active_one": "{{count}} פעיל",
|
||||
"{{count}} active_two": "{{count}} פעילים",
|
||||
"{{count}} active_other": "{{count}} פעילים",
|
||||
"{{count}} downloads_one": "הורדה אחת",
|
||||
"{{count}} downloads_two": "{{count}} הורדות",
|
||||
"{{count}} downloads_other": "{{count}} הורדות",
|
||||
"starts at saved page": "מתחיל בעמוד השמור",
|
||||
"More actions": "פעולות נוספות",
|
||||
"Loading…": "טוען…",
|
||||
"Load more": "טען עוד",
|
||||
"Could not load shared book": "לא ניתן היה לטעון את הספר המשותף",
|
||||
"The share link is missing required information.": "בקישור השיתוף חסר מידע נדרש.",
|
||||
"Please check your connection and try again.": "בדוק את החיבור שלך ונסה שוב.",
|
||||
"Sign in to import shared books": "היכנס כדי לייבא ספרים משותפים",
|
||||
"Already in your library": "כבר בספרייה שלך",
|
||||
"Added to your library": "נוסף לספרייה שלך",
|
||||
"Could not import shared book": "לא ניתן היה לייבא את הספר המשותף",
|
||||
"Manage Shared Links": "ניהול קישורי שיתוף",
|
||||
"Share current page": "שתף את העמוד הנוכחי",
|
||||
"Expires in": "יפוג בעוד",
|
||||
"{{count}} days_one": "יום אחד",
|
||||
"{{count}} days_two": "יומיים",
|
||||
"{{count}} days_other": "{{count}} ימים",
|
||||
"Expires in {{count}} days_one": "יפוג בעוד יום",
|
||||
"Expires in {{count}} days_two": "יפוג בעוד יומיים",
|
||||
"Expires in {{count}} days_other": "יפוג בעוד {{count}} ימים",
|
||||
"Expires in {{count}} hours_one": "יפוג בעוד שעה",
|
||||
"Expires in {{count}} hours_two": "יפוג בעוד שעתיים",
|
||||
"Expires in {{count}} hours_other": "יפוג בעוד {{count}} שעות",
|
||||
"Open in app": "פתח באפליקציה",
|
||||
"Shared with you": "שותף איתך"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Readest ऐप में खोलें",
|
||||
"Continue in browser": "ब्राउज़र में जारी रखें",
|
||||
"Don't have Readest?": "Readest नहीं है?",
|
||||
"Book not in your library": "यह किताब आपकी लाइब्रेरी में नहीं है"
|
||||
"Book not in your library": "यह किताब आपकी लाइब्रेरी में नहीं है",
|
||||
"Share Book": "पुस्तक साझा करें",
|
||||
"Could not create share link": "साझा लिंक नहीं बनाया जा सका",
|
||||
"Link copied": "लिंक कॉपी किया गया",
|
||||
"Could not copy link": "लिंक कॉपी नहीं किया जा सका",
|
||||
"Share revoked": "साझा रद्द किया गया",
|
||||
"Could not revoke share": "साझा रद्द नहीं किया जा सका",
|
||||
"Uploading book…": "पुस्तक अपलोड हो रही है…",
|
||||
"Generating…": "बना रहा है…",
|
||||
"Generate share link": "साझा लिंक बनाएँ",
|
||||
"Includes your current page": "आपका वर्तमान पृष्ठ शामिल है",
|
||||
"Share URL": "साझा URL",
|
||||
"Copy link": "लिंक कॉपी करें",
|
||||
"Copied": "कॉपी किया गया",
|
||||
"Share via…": "के माध्यम से साझा करें…",
|
||||
"Expires {{date}}": "{{date}} को समाप्त",
|
||||
"Revoking…": "रद्द कर रहा है…",
|
||||
"Revoke share": "साझा रद्द करें",
|
||||
"Sign in to share books": "पुस्तकें साझा करने के लिए साइन इन करें",
|
||||
"Expiring soon": "जल्द ही समाप्त होगा",
|
||||
"Missing share token": "साझा टोकन गायब है",
|
||||
"Could not add to your library": "आपकी लाइब्रेरी में जोड़ा नहीं जा सका",
|
||||
"This share link is no longer available": "यह साझा लिंक अब उपलब्ध नहीं है",
|
||||
"The original link may have expired or been revoked.": "मूल लिंक समाप्त हो गया हो सकता है या रद्द कर दिया गया हो।",
|
||||
"Get Readest": "Readest प्राप्त करें",
|
||||
"Loading shared book…": "साझा पुस्तक लोड हो रही है…",
|
||||
"Adding…": "जोड़ रहा है…",
|
||||
"Add to my library": "मेरी लाइब्रेरी में जोड़ें",
|
||||
"Could not load your shares": "आपके साझा लोड नहीं किए जा सके",
|
||||
"Revoked": "रद्द",
|
||||
"Expired": "समाप्त",
|
||||
"Shared books": "साझा पुस्तकें",
|
||||
"You haven't shared any books yet": "आपने अभी तक कोई पुस्तक साझा नहीं की है",
|
||||
"Open a book and tap Share to send it to a friend.": "किसी पुस्तक को खोलें और मित्र को भेजने के लिए साझा पर टैप करें।",
|
||||
"{{count}} active_one": "{{count}} सक्रिय",
|
||||
"{{count}} active_other": "{{count}} सक्रिय",
|
||||
"{{count}} downloads_one": "{{count}} डाउनलोड",
|
||||
"{{count}} downloads_other": "{{count}} डाउनलोड",
|
||||
"starts at saved page": "सहेजे गए पृष्ठ से शुरू",
|
||||
"More actions": "अधिक क्रियाएँ",
|
||||
"Loading…": "लोड हो रहा है…",
|
||||
"Load more": "और लोड करें",
|
||||
"Could not load shared book": "साझा पुस्तक लोड नहीं की जा सकी",
|
||||
"The share link is missing required information.": "साझा लिंक में आवश्यक जानकारी नहीं है।",
|
||||
"Please check your connection and try again.": "कृपया अपना कनेक्शन जांचें और पुनः प्रयास करें।",
|
||||
"Sign in to import shared books": "साझा पुस्तकें आयात करने के लिए साइन इन करें",
|
||||
"Already in your library": "पहले से ही आपकी लाइब्रेरी में है",
|
||||
"Added to your library": "आपकी लाइब्रेरी में जोड़ा गया",
|
||||
"Could not import shared book": "साझा पुस्तक आयात नहीं की जा सकी",
|
||||
"Manage Shared Links": "साझा लिंक प्रबंधित करें",
|
||||
"Share current page": "वर्तमान पृष्ठ साझा करें",
|
||||
"Expires in": "समाप्त होगा",
|
||||
"{{count}} days_one": "1 दिन",
|
||||
"{{count}} days_other": "{{count}} दिन",
|
||||
"Expires in {{count}} days_one": "1 दिन में समाप्त",
|
||||
"Expires in {{count}} days_other": "{{count}} दिनों में समाप्त",
|
||||
"Expires in {{count}} hours_one": "1 घंटे में समाप्त",
|
||||
"Expires in {{count}} hours_other": "{{count}} घंटों में समाप्त",
|
||||
"Open in app": "ऐप में खोलें",
|
||||
"Shared with you": "आपके साथ साझा किया गया"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Megnyitás a Readest alkalmazásban",
|
||||
"Continue in browser": "Folytatás a böngészőben",
|
||||
"Don't have Readest?": "Még nincs Readested?",
|
||||
"Book not in your library": "A könyv nincs a könyvtáradban"
|
||||
"Book not in your library": "A könyv nincs a könyvtáradban",
|
||||
"Share Book": "Könyv megosztása",
|
||||
"Could not create share link": "A megosztási link létrehozása nem sikerült",
|
||||
"Link copied": "Link másolva",
|
||||
"Could not copy link": "A link másolása nem sikerült",
|
||||
"Share revoked": "Megosztás visszavonva",
|
||||
"Could not revoke share": "A megosztás visszavonása nem sikerült",
|
||||
"Uploading book…": "Könyv feltöltése…",
|
||||
"Generating…": "Létrehozás…",
|
||||
"Generate share link": "Megosztási link létrehozása",
|
||||
"Includes your current page": "Tartalmazza az aktuális oldalt",
|
||||
"Share URL": "Megosztási URL",
|
||||
"Copy link": "Link másolása",
|
||||
"Copied": "Másolva",
|
||||
"Share via…": "Megosztás ezzel…",
|
||||
"Expires {{date}}": "Lejár: {{date}}",
|
||||
"Revoking…": "Visszavonás…",
|
||||
"Revoke share": "Megosztás visszavonása",
|
||||
"Sign in to share books": "Jelentkezz be a könyvek megosztásához",
|
||||
"Expiring soon": "Hamarosan lejár",
|
||||
"Missing share token": "Hiányzó megosztási token",
|
||||
"Could not add to your library": "Nem sikerült hozzáadni a könyvtáradhoz",
|
||||
"This share link is no longer available": "Ez a megosztási link már nem érhető el",
|
||||
"The original link may have expired or been revoked.": "Az eredeti link lehet, hogy lejárt vagy visszavonták.",
|
||||
"Get Readest": "Readest letöltése",
|
||||
"Loading shared book…": "Megosztott könyv betöltése…",
|
||||
"Adding…": "Hozzáadás…",
|
||||
"Add to my library": "Hozzáadás a könyvtáramhoz",
|
||||
"Could not load your shares": "Nem sikerült betölteni a megosztásaidat",
|
||||
"Revoked": "Visszavonva",
|
||||
"Expired": "Lejárt",
|
||||
"Shared books": "Megosztott könyvek",
|
||||
"You haven't shared any books yet": "Még nem osztottál meg könyvet",
|
||||
"Open a book and tap Share to send it to a friend.": "Nyiss meg egy könyvet, és koppints a Megosztásra, hogy elküldd egy barátodnak.",
|
||||
"{{count}} active_one": "{{count}} aktív",
|
||||
"{{count}} active_other": "{{count}} aktív",
|
||||
"{{count}} downloads_one": "{{count}} letöltés",
|
||||
"{{count}} downloads_other": "{{count}} letöltés",
|
||||
"starts at saved page": "mentett oldaltól indul",
|
||||
"More actions": "További műveletek",
|
||||
"Loading…": "Betöltés…",
|
||||
"Load more": "Több betöltése",
|
||||
"Could not load shared book": "A megosztott könyv betöltése nem sikerült",
|
||||
"The share link is missing required information.": "A megosztási linkről hiányoznak szükséges adatok.",
|
||||
"Please check your connection and try again.": "Ellenőrizd a kapcsolatot, majd próbáld újra.",
|
||||
"Sign in to import shared books": "Jelentkezz be a megosztott könyvek importálásához",
|
||||
"Already in your library": "Már a könyvtáradban van",
|
||||
"Added to your library": "Hozzáadva a könyvtáradhoz",
|
||||
"Could not import shared book": "A megosztott könyv importálása nem sikerült",
|
||||
"Manage Shared Links": "Megosztási linkek kezelése",
|
||||
"Share current page": "Aktuális oldal megosztása",
|
||||
"Expires in": "Lejár",
|
||||
"{{count}} days_one": "1 nap",
|
||||
"{{count}} days_other": "{{count}} nap",
|
||||
"Expires in {{count}} days_one": "Lejár 1 nap múlva",
|
||||
"Expires in {{count}} days_other": "Lejár {{count}} nap múlva",
|
||||
"Expires in {{count}} hours_one": "Lejár 1 óra múlva",
|
||||
"Expires in {{count}} hours_other": "Lejár {{count}} óra múlva",
|
||||
"Open in app": "Megnyitás az alkalmazásban",
|
||||
"Shared with you": "Megosztva veled"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "Buka di aplikasi Readest",
|
||||
"Continue in browser": "Lanjutkan di peramban",
|
||||
"Don't have Readest?": "Belum punya Readest?",
|
||||
"Book not in your library": "Buku tidak ada di pustaka Anda"
|
||||
"Book not in your library": "Buku tidak ada di pustaka Anda",
|
||||
"Share Book": "Bagikan Buku",
|
||||
"Could not create share link": "Tidak dapat membuat tautan berbagi",
|
||||
"Link copied": "Tautan disalin",
|
||||
"Could not copy link": "Tidak dapat menyalin tautan",
|
||||
"Share revoked": "Berbagi dibatalkan",
|
||||
"Could not revoke share": "Tidak dapat membatalkan berbagi",
|
||||
"Uploading book…": "Mengunggah buku…",
|
||||
"Generating…": "Membuat…",
|
||||
"Generate share link": "Buat tautan berbagi",
|
||||
"Includes your current page": "Termasuk halaman Anda saat ini",
|
||||
"Share URL": "URL Berbagi",
|
||||
"Copy link": "Salin tautan",
|
||||
"Copied": "Disalin",
|
||||
"Share via…": "Bagikan melalui…",
|
||||
"Expires {{date}}": "Kedaluwarsa pada {{date}}",
|
||||
"Revoking…": "Membatalkan…",
|
||||
"Revoke share": "Batalkan berbagi",
|
||||
"Sign in to share books": "Masuk untuk berbagi buku",
|
||||
"Expiring soon": "Segera kedaluwarsa",
|
||||
"Missing share token": "Token berbagi hilang",
|
||||
"Could not add to your library": "Tidak dapat ditambahkan ke pustaka Anda",
|
||||
"This share link is no longer available": "Tautan berbagi ini tidak lagi tersedia",
|
||||
"The original link may have expired or been revoked.": "Tautan asli mungkin telah kedaluwarsa atau dibatalkan.",
|
||||
"Get Readest": "Dapatkan Readest",
|
||||
"Loading shared book…": "Memuat buku yang dibagikan…",
|
||||
"Adding…": "Menambahkan…",
|
||||
"Add to my library": "Tambahkan ke pustaka saya",
|
||||
"Could not load your shares": "Tidak dapat memuat berbagi Anda",
|
||||
"Revoked": "Dibatalkan",
|
||||
"Expired": "Kedaluwarsa",
|
||||
"Shared books": "Buku yang dibagikan",
|
||||
"You haven't shared any books yet": "Anda belum membagikan buku apa pun",
|
||||
"Open a book and tap Share to send it to a friend.": "Buka buku dan ketuk Bagikan untuk mengirimnya ke teman.",
|
||||
"{{count}} active_other": "{{count}} aktif",
|
||||
"{{count}} downloads_other": "{{count}} unduhan",
|
||||
"starts at saved page": "mulai dari halaman tersimpan",
|
||||
"More actions": "Tindakan lainnya",
|
||||
"Loading…": "Memuat…",
|
||||
"Load more": "Muat lainnya",
|
||||
"Could not load shared book": "Tidak dapat memuat buku yang dibagikan",
|
||||
"The share link is missing required information.": "Tautan berbagi tidak memiliki informasi yang diperlukan.",
|
||||
"Please check your connection and try again.": "Periksa koneksi Anda dan coba lagi.",
|
||||
"Sign in to import shared books": "Masuk untuk mengimpor buku yang dibagikan",
|
||||
"Already in your library": "Sudah ada di pustaka Anda",
|
||||
"Added to your library": "Ditambahkan ke pustaka Anda",
|
||||
"Could not import shared book": "Tidak dapat mengimpor buku yang dibagikan",
|
||||
"Manage Shared Links": "Kelola tautan berbagi",
|
||||
"Share current page": "Bagikan halaman saat ini",
|
||||
"Expires in": "Kedaluwarsa dalam",
|
||||
"{{count}} days_other": "{{count}} hari",
|
||||
"Expires in {{count}} days_other": "Kedaluwarsa dalam {{count}} hari",
|
||||
"Expires in {{count}} hours_other": "Kedaluwarsa dalam {{count}} jam",
|
||||
"Open in app": "Buka di aplikasi",
|
||||
"Shared with you": "Dibagikan dengan Anda"
|
||||
}
|
||||
|
||||
@@ -1273,5 +1273,69 @@
|
||||
"Open in Readest app": "Apri nell'app Readest",
|
||||
"Continue in browser": "Continua nel browser",
|
||||
"Don't have Readest?": "Non hai Readest?",
|
||||
"Book not in your library": "Libro non presente nella tua libreria"
|
||||
"Book not in your library": "Libro non presente nella tua libreria",
|
||||
"Share Book": "Condividi libro",
|
||||
"Could not create share link": "Impossibile creare il link di condivisione",
|
||||
"Link copied": "Link copiato",
|
||||
"Could not copy link": "Impossibile copiare il link",
|
||||
"Share revoked": "Condivisione revocata",
|
||||
"Could not revoke share": "Impossibile revocare la condivisione",
|
||||
"Uploading book…": "Caricamento libro…",
|
||||
"Generating…": "Generazione…",
|
||||
"Generate share link": "Genera link di condivisione",
|
||||
"Includes your current page": "Include la tua pagina attuale",
|
||||
"Share URL": "URL di condivisione",
|
||||
"Copy link": "Copia link",
|
||||
"Copied": "Copiato",
|
||||
"Share via…": "Condividi tramite…",
|
||||
"Expires {{date}}": "Scade il {{date}}",
|
||||
"Revoking…": "Revoca in corso…",
|
||||
"Revoke share": "Revoca condivisione",
|
||||
"Sign in to share books": "Accedi per condividere libri",
|
||||
"Expiring soon": "Scade presto",
|
||||
"Missing share token": "Token di condivisione mancante",
|
||||
"Could not add to your library": "Impossibile aggiungere alla tua libreria",
|
||||
"This share link is no longer available": "Questo link di condivisione non è più disponibile",
|
||||
"The original link may have expired or been revoked.": "Il link originale potrebbe essere scaduto o revocato.",
|
||||
"Get Readest": "Ottieni Readest",
|
||||
"Loading shared book…": "Caricamento libro condiviso…",
|
||||
"Adding…": "Aggiunta in corso…",
|
||||
"Add to my library": "Aggiungi alla mia libreria",
|
||||
"Could not load your shares": "Impossibile caricare le tue condivisioni",
|
||||
"Revoked": "Revocato",
|
||||
"Expired": "Scaduto",
|
||||
"Shared books": "Libri condivisi",
|
||||
"You haven't shared any books yet": "Non hai ancora condiviso alcun libro",
|
||||
"Open a book and tap Share to send it to a friend.": "Apri un libro e tocca Condividi per inviarlo a un amico.",
|
||||
"{{count}} active_one": "{{count}} attivo",
|
||||
"{{count}} active_many": "{{count}} attivi",
|
||||
"{{count}} active_other": "{{count}} attivi",
|
||||
"{{count}} downloads_one": "{{count}} download",
|
||||
"{{count}} downloads_many": "{{count}} download",
|
||||
"{{count}} downloads_other": "{{count}} download",
|
||||
"starts at saved page": "inizia dalla pagina salvata",
|
||||
"More actions": "Altre azioni",
|
||||
"Loading…": "Caricamento…",
|
||||
"Load more": "Carica altro",
|
||||
"Could not load shared book": "Impossibile caricare il libro condiviso",
|
||||
"The share link is missing required information.": "Al link di condivisione mancano informazioni necessarie.",
|
||||
"Please check your connection and try again.": "Controlla la connessione e riprova.",
|
||||
"Sign in to import shared books": "Accedi per importare libri condivisi",
|
||||
"Already in your library": "Già nella tua libreria",
|
||||
"Added to your library": "Aggiunto alla tua libreria",
|
||||
"Could not import shared book": "Impossibile importare il libro condiviso",
|
||||
"Manage Shared Links": "Gestisci link di condivisione",
|
||||
"Share current page": "Condividi pagina attuale",
|
||||
"Expires in": "Scade tra",
|
||||
"{{count}} days_one": "1 giorno",
|
||||
"{{count}} days_many": "{{count}} giorni",
|
||||
"{{count}} days_other": "{{count}} giorni",
|
||||
"Expires in {{count}} days_one": "Scade tra 1 giorno",
|
||||
"Expires in {{count}} days_many": "Scade tra {{count}} giorni",
|
||||
"Expires in {{count}} days_other": "Scade tra {{count}} giorni",
|
||||
"Expires in {{count}} hours_one": "Scade tra 1 ora",
|
||||
"Expires in {{count}} hours_many": "Scade tra {{count}} ore",
|
||||
"Expires in {{count}} hours_other": "Scade tra {{count}} ore",
|
||||
"Open in app": "Apri nell'app",
|
||||
"Shared with you": "Condiviso con te"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "Readest アプリで開く",
|
||||
"Continue in browser": "ブラウザーで続ける",
|
||||
"Don't have Readest?": "Readest をお持ちでない方は",
|
||||
"Book not in your library": "この本はライブラリにありません"
|
||||
"Book not in your library": "この本はライブラリにありません",
|
||||
"Share Book": "本を共有",
|
||||
"Could not create share link": "共有リンクを作成できませんでした",
|
||||
"Link copied": "リンクをコピーしました",
|
||||
"Could not copy link": "リンクをコピーできませんでした",
|
||||
"Share revoked": "共有を取り消しました",
|
||||
"Could not revoke share": "共有を取り消せませんでした",
|
||||
"Uploading book…": "本をアップロード中…",
|
||||
"Generating…": "生成中…",
|
||||
"Generate share link": "共有リンクを作成",
|
||||
"Includes your current page": "現在のページを含む",
|
||||
"Share URL": "共有URL",
|
||||
"Copy link": "リンクをコピー",
|
||||
"Copied": "コピーしました",
|
||||
"Share via…": "共有…",
|
||||
"Expires {{date}}": "{{date}}に期限切れ",
|
||||
"Revoking…": "取り消し中…",
|
||||
"Revoke share": "共有を取り消す",
|
||||
"Sign in to share books": "本を共有するにはサインインしてください",
|
||||
"Expiring soon": "まもなく期限切れ",
|
||||
"Missing share token": "共有トークンが見つかりません",
|
||||
"Could not add to your library": "ライブラリに追加できませんでした",
|
||||
"This share link is no longer available": "この共有リンクはもう利用できません",
|
||||
"The original link may have expired or been revoked.": "元のリンクは期限切れか取り消された可能性があります。",
|
||||
"Get Readest": "Readest を入手",
|
||||
"Loading shared book…": "共有された本を読み込み中…",
|
||||
"Adding…": "追加中…",
|
||||
"Add to my library": "ライブラリに追加",
|
||||
"Could not load your shares": "共有を読み込めませんでした",
|
||||
"Revoked": "取り消し済み",
|
||||
"Expired": "期限切れ",
|
||||
"Shared books": "共有された本",
|
||||
"You haven't shared any books yet": "まだ本を共有していません",
|
||||
"Open a book and tap Share to send it to a friend.": "本を開き、共有をタップして友達に送りましょう。",
|
||||
"{{count}} active_other": "{{count}} 件アクティブ",
|
||||
"{{count}} downloads_other": "{{count}} 件のダウンロード",
|
||||
"starts at saved page": "保存されたページから開始",
|
||||
"More actions": "その他の操作",
|
||||
"Loading…": "読み込み中…",
|
||||
"Load more": "さらに読み込む",
|
||||
"Could not load shared book": "共有された本を読み込めませんでした",
|
||||
"The share link is missing required information.": "共有リンクに必要な情報が含まれていません。",
|
||||
"Please check your connection and try again.": "接続を確認してもう一度お試しください。",
|
||||
"Sign in to import shared books": "共有された本を取り込むにはサインインしてください",
|
||||
"Already in your library": "すでにライブラリにあります",
|
||||
"Added to your library": "ライブラリに追加しました",
|
||||
"Could not import shared book": "共有された本を取り込めませんでした",
|
||||
"Manage Shared Links": "共有リンクを管理",
|
||||
"Share current page": "現在のページを共有",
|
||||
"Expires in": "有効期限",
|
||||
"{{count}} days_other": "{{count}}日",
|
||||
"Expires in {{count}} days_other": "{{count}}日で期限切れ",
|
||||
"Expires in {{count}} hours_other": "{{count}}時間で期限切れ",
|
||||
"Open in app": "アプリで開く",
|
||||
"Shared with you": "あなたへの共有"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "Readest 앱에서 열기",
|
||||
"Continue in browser": "브라우저에서 계속",
|
||||
"Don't have Readest?": "Readest가 없으신가요?",
|
||||
"Book not in your library": "책이 라이브러리에 없습니다"
|
||||
"Book not in your library": "책이 라이브러리에 없습니다",
|
||||
"Share Book": "책 공유",
|
||||
"Could not create share link": "공유 링크를 만들 수 없습니다",
|
||||
"Link copied": "링크가 복사되었습니다",
|
||||
"Could not copy link": "링크를 복사할 수 없습니다",
|
||||
"Share revoked": "공유가 취소되었습니다",
|
||||
"Could not revoke share": "공유를 취소할 수 없습니다",
|
||||
"Uploading book…": "책 업로드 중…",
|
||||
"Generating…": "생성 중…",
|
||||
"Generate share link": "공유 링크 만들기",
|
||||
"Includes your current page": "현재 페이지 포함",
|
||||
"Share URL": "공유 URL",
|
||||
"Copy link": "링크 복사",
|
||||
"Copied": "복사됨",
|
||||
"Share via…": "공유…",
|
||||
"Expires {{date}}": "{{date}}에 만료",
|
||||
"Revoking…": "취소 중…",
|
||||
"Revoke share": "공유 취소",
|
||||
"Sign in to share books": "책을 공유하려면 로그인하세요",
|
||||
"Expiring soon": "곧 만료",
|
||||
"Missing share token": "공유 토큰이 없습니다",
|
||||
"Could not add to your library": "내 라이브러리에 추가할 수 없습니다",
|
||||
"This share link is no longer available": "이 공유 링크는 더 이상 사용할 수 없습니다",
|
||||
"The original link may have expired or been revoked.": "원래 링크가 만료되었거나 취소되었을 수 있습니다.",
|
||||
"Get Readest": "Readest 받기",
|
||||
"Loading shared book…": "공유된 책을 불러오는 중…",
|
||||
"Adding…": "추가 중…",
|
||||
"Add to my library": "내 라이브러리에 추가",
|
||||
"Could not load your shares": "공유를 불러올 수 없습니다",
|
||||
"Revoked": "취소됨",
|
||||
"Expired": "만료됨",
|
||||
"Shared books": "공유된 책",
|
||||
"You haven't shared any books yet": "아직 책을 공유하지 않았습니다",
|
||||
"Open a book and tap Share to send it to a friend.": "책을 열고 공유를 눌러 친구에게 보내세요.",
|
||||
"{{count}} active_other": "활성 {{count}}개",
|
||||
"{{count}} downloads_other": "{{count}}회 다운로드",
|
||||
"starts at saved page": "저장된 페이지에서 시작",
|
||||
"More actions": "추가 작업",
|
||||
"Loading…": "불러오는 중…",
|
||||
"Load more": "더 보기",
|
||||
"Could not load shared book": "공유된 책을 불러올 수 없습니다",
|
||||
"The share link is missing required information.": "공유 링크에 필요한 정보가 없습니다.",
|
||||
"Please check your connection and try again.": "연결을 확인한 후 다시 시도하세요.",
|
||||
"Sign in to import shared books": "공유된 책을 가져오려면 로그인하세요",
|
||||
"Already in your library": "이미 라이브러리에 있습니다",
|
||||
"Added to your library": "라이브러리에 추가되었습니다",
|
||||
"Could not import shared book": "공유된 책을 가져올 수 없습니다",
|
||||
"Manage Shared Links": "공유 링크 관리",
|
||||
"Share current page": "현재 페이지 공유",
|
||||
"Expires in": "만료",
|
||||
"{{count}} days_other": "{{count}}일",
|
||||
"Expires in {{count}} days_other": "{{count}}일 후 만료",
|
||||
"Expires in {{count}} hours_other": "{{count}}시간 후 만료",
|
||||
"Open in app": "앱에서 열기",
|
||||
"Shared with you": "회원님과 공유됨"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "Buka dalam aplikasi Readest",
|
||||
"Continue in browser": "Teruskan dalam pelayar",
|
||||
"Don't have Readest?": "Tiada Readest?",
|
||||
"Book not in your library": "Buku tiada dalam pustaka anda"
|
||||
"Book not in your library": "Buku tiada dalam pustaka anda",
|
||||
"Share Book": "Kongsi Buku",
|
||||
"Could not create share link": "Tidak dapat mencipta pautan kongsi",
|
||||
"Link copied": "Pautan disalin",
|
||||
"Could not copy link": "Tidak dapat menyalin pautan",
|
||||
"Share revoked": "Perkongsian dibatalkan",
|
||||
"Could not revoke share": "Tidak dapat membatalkan perkongsian",
|
||||
"Uploading book…": "Memuat naik buku…",
|
||||
"Generating…": "Menjana…",
|
||||
"Generate share link": "Jana pautan kongsi",
|
||||
"Includes your current page": "Termasuk halaman semasa anda",
|
||||
"Share URL": "URL Kongsi",
|
||||
"Copy link": "Salin pautan",
|
||||
"Copied": "Disalin",
|
||||
"Share via…": "Kongsi melalui…",
|
||||
"Expires {{date}}": "Tamat pada {{date}}",
|
||||
"Revoking…": "Membatalkan…",
|
||||
"Revoke share": "Batalkan perkongsian",
|
||||
"Sign in to share books": "Log masuk untuk berkongsi buku",
|
||||
"Expiring soon": "Akan tamat tidak lama lagi",
|
||||
"Missing share token": "Token kongsi hilang",
|
||||
"Could not add to your library": "Tidak dapat ditambah ke pustaka anda",
|
||||
"This share link is no longer available": "Pautan kongsi ini tidak lagi tersedia",
|
||||
"The original link may have expired or been revoked.": "Pautan asal mungkin telah tamat tempoh atau dibatalkan.",
|
||||
"Get Readest": "Dapatkan Readest",
|
||||
"Loading shared book…": "Memuat buku yang dikongsi…",
|
||||
"Adding…": "Menambah…",
|
||||
"Add to my library": "Tambah ke pustaka saya",
|
||||
"Could not load your shares": "Tidak dapat memuat perkongsian anda",
|
||||
"Revoked": "Dibatalkan",
|
||||
"Expired": "Tamat tempoh",
|
||||
"Shared books": "Buku yang dikongsi",
|
||||
"You haven't shared any books yet": "Anda belum berkongsi sebarang buku",
|
||||
"Open a book and tap Share to send it to a friend.": "Buka buku dan ketik Kongsi untuk menghantarnya kepada rakan.",
|
||||
"{{count}} active_other": "{{count}} aktif",
|
||||
"{{count}} downloads_other": "{{count}} muat turun",
|
||||
"starts at saved page": "mula dari halaman tersimpan",
|
||||
"More actions": "Tindakan lain",
|
||||
"Loading…": "Memuat…",
|
||||
"Load more": "Muat lagi",
|
||||
"Could not load shared book": "Tidak dapat memuat buku yang dikongsi",
|
||||
"The share link is missing required information.": "Pautan kongsi tidak mempunyai maklumat yang diperlukan.",
|
||||
"Please check your connection and try again.": "Sila semak sambungan anda dan cuba lagi.",
|
||||
"Sign in to import shared books": "Log masuk untuk mengimport buku yang dikongsi",
|
||||
"Already in your library": "Sudah ada dalam pustaka anda",
|
||||
"Added to your library": "Ditambah ke pustaka anda",
|
||||
"Could not import shared book": "Tidak dapat mengimport buku yang dikongsi",
|
||||
"Manage Shared Links": "Urus pautan kongsi",
|
||||
"Share current page": "Kongsi halaman semasa",
|
||||
"Expires in": "Tamat dalam",
|
||||
"{{count}} days_other": "{{count}} hari",
|
||||
"Expires in {{count}} days_other": "Tamat dalam {{count}} hari",
|
||||
"Expires in {{count}} hours_other": "Tamat dalam {{count}} jam",
|
||||
"Open in app": "Buka dalam app",
|
||||
"Shared with you": "Dikongsi dengan anda"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Openen in de Readest-app",
|
||||
"Continue in browser": "Doorgaan in de browser",
|
||||
"Don't have Readest?": "Heb je Readest nog niet?",
|
||||
"Book not in your library": "Boek staat niet in je bibliotheek"
|
||||
"Book not in your library": "Boek staat niet in je bibliotheek",
|
||||
"Share Book": "Boek delen",
|
||||
"Could not create share link": "Kan deelkoppeling niet maken",
|
||||
"Link copied": "Koppeling gekopieerd",
|
||||
"Could not copy link": "Kan koppeling niet kopiëren",
|
||||
"Share revoked": "Delen ingetrokken",
|
||||
"Could not revoke share": "Kan delen niet intrekken",
|
||||
"Uploading book…": "Boek uploaden…",
|
||||
"Generating…": "Genereren…",
|
||||
"Generate share link": "Deellink maken",
|
||||
"Includes your current page": "Bevat uw huidige pagina",
|
||||
"Share URL": "Deel-URL",
|
||||
"Copy link": "Koppeling kopiëren",
|
||||
"Copied": "Gekopieerd",
|
||||
"Share via…": "Delen via…",
|
||||
"Expires {{date}}": "Verloopt op {{date}}",
|
||||
"Revoking…": "Intrekken…",
|
||||
"Revoke share": "Delen intrekken",
|
||||
"Sign in to share books": "Meld u aan om boeken te delen",
|
||||
"Expiring soon": "Verloopt binnenkort",
|
||||
"Missing share token": "Deeltoken ontbreekt",
|
||||
"Could not add to your library": "Kon niet aan je bibliotheek worden toegevoegd",
|
||||
"This share link is no longer available": "Deze deelkoppeling is niet meer beschikbaar",
|
||||
"The original link may have expired or been revoked.": "De oorspronkelijke koppeling is mogelijk verlopen of ingetrokken.",
|
||||
"Get Readest": "Readest downloaden",
|
||||
"Loading shared book…": "Gedeeld boek laden…",
|
||||
"Adding…": "Toevoegen…",
|
||||
"Add to my library": "Aan mijn bibliotheek toevoegen",
|
||||
"Could not load your shares": "Kan uw delingen niet laden",
|
||||
"Revoked": "Ingetrokken",
|
||||
"Expired": "Verlopen",
|
||||
"Shared books": "Gedeelde boeken",
|
||||
"You haven't shared any books yet": "U heeft nog geen boeken gedeeld",
|
||||
"Open a book and tap Share to send it to a friend.": "Open een boek en tik op Delen om het naar een vriend te sturen.",
|
||||
"{{count}} active_one": "{{count}} actief",
|
||||
"{{count}} active_other": "{{count}} actief",
|
||||
"{{count}} downloads_one": "{{count}} download",
|
||||
"{{count}} downloads_other": "{{count}} downloads",
|
||||
"starts at saved page": "begint op opgeslagen pagina",
|
||||
"More actions": "Meer acties",
|
||||
"Loading…": "Laden…",
|
||||
"Load more": "Meer laden",
|
||||
"Could not load shared book": "Kan gedeeld boek niet laden",
|
||||
"The share link is missing required information.": "In de deellink ontbreekt vereiste informatie.",
|
||||
"Please check your connection and try again.": "Controleer uw verbinding en probeer het opnieuw.",
|
||||
"Sign in to import shared books": "Meld u aan om gedeelde boeken te importeren",
|
||||
"Already in your library": "Al in uw bibliotheek",
|
||||
"Added to your library": "Toegevoegd aan uw bibliotheek",
|
||||
"Could not import shared book": "Kan gedeeld boek niet importeren",
|
||||
"Manage Shared Links": "Deelkoppelingen beheren",
|
||||
"Share current page": "Huidige pagina delen",
|
||||
"Expires in": "Verloopt over",
|
||||
"{{count}} days_one": "1 dag",
|
||||
"{{count}} days_other": "{{count}} dagen",
|
||||
"Expires in {{count}} days_one": "Verloopt over 1 dag",
|
||||
"Expires in {{count}} days_other": "Verloopt over {{count}} dagen",
|
||||
"Expires in {{count}} hours_one": "Verloopt over 1 uur",
|
||||
"Expires in {{count}} hours_other": "Verloopt over {{count}} uur",
|
||||
"Open in app": "Openen in app",
|
||||
"Shared with you": "Met u gedeeld"
|
||||
}
|
||||
|
||||
@@ -1291,5 +1291,74 @@
|
||||
"Open in Readest app": "Otwórz w aplikacji Readest",
|
||||
"Continue in browser": "Kontynuuj w przeglądarce",
|
||||
"Don't have Readest?": "Nie masz jeszcze Readest?",
|
||||
"Book not in your library": "Książki nie ma w Twojej bibliotece"
|
||||
"Book not in your library": "Książki nie ma w Twojej bibliotece",
|
||||
"Share Book": "Udostępnij książkę",
|
||||
"Could not create share link": "Nie można utworzyć linku udostępniania",
|
||||
"Link copied": "Link skopiowany",
|
||||
"Could not copy link": "Nie można skopiować linku",
|
||||
"Share revoked": "Udostępnianie odwołane",
|
||||
"Could not revoke share": "Nie można odwołać udostępniania",
|
||||
"Uploading book…": "Przesyłanie książki…",
|
||||
"Generating…": "Generowanie…",
|
||||
"Generate share link": "Utwórz link udostępniania",
|
||||
"Includes your current page": "Zawiera Twoją bieżącą stronę",
|
||||
"Share URL": "Adres udostępniania",
|
||||
"Copy link": "Kopiuj link",
|
||||
"Copied": "Skopiowano",
|
||||
"Share via…": "Udostępnij przez…",
|
||||
"Expires {{date}}": "Wygasa {{date}}",
|
||||
"Revoking…": "Odwoływanie…",
|
||||
"Revoke share": "Odwołaj udostępnianie",
|
||||
"Sign in to share books": "Zaloguj się, aby udostępniać książki",
|
||||
"Expiring soon": "Wkrótce wygaśnie",
|
||||
"Missing share token": "Brakuje tokenu udostępniania",
|
||||
"Could not add to your library": "Nie można dodać do biblioteki",
|
||||
"This share link is no longer available": "Ten link udostępniania jest już niedostępny",
|
||||
"The original link may have expired or been revoked.": "Pierwotny link mógł wygasnąć lub został odwołany.",
|
||||
"Get Readest": "Pobierz Readest",
|
||||
"Loading shared book…": "Wczytywanie udostępnionej książki…",
|
||||
"Adding…": "Dodawanie…",
|
||||
"Add to my library": "Dodaj do mojej biblioteki",
|
||||
"Could not load your shares": "Nie można wczytać udostępnień",
|
||||
"Revoked": "Odwołany",
|
||||
"Expired": "Wygasł",
|
||||
"Shared books": "Udostępnione książki",
|
||||
"You haven't shared any books yet": "Nie udostępniłeś jeszcze żadnych książek",
|
||||
"Open a book and tap Share to send it to a friend.": "Otwórz książkę i dotknij Udostępnij, aby wysłać ją znajomemu.",
|
||||
"{{count}} active_one": "{{count}} aktywny",
|
||||
"{{count}} active_few": "{{count}} aktywne",
|
||||
"{{count}} active_many": "{{count}} aktywnych",
|
||||
"{{count}} active_other": "{{count}} aktywnych",
|
||||
"{{count}} downloads_one": "{{count}} pobranie",
|
||||
"{{count}} downloads_few": "{{count}} pobrania",
|
||||
"{{count}} downloads_many": "{{count}} pobrań",
|
||||
"{{count}} downloads_other": "{{count}} pobrań",
|
||||
"starts at saved page": "zaczyna się od zapisanej strony",
|
||||
"More actions": "Więcej akcji",
|
||||
"Loading…": "Wczytywanie…",
|
||||
"Load more": "Wczytaj więcej",
|
||||
"Could not load shared book": "Nie można wczytać udostępnionej książki",
|
||||
"The share link is missing required information.": "W linku udostępniania brakuje wymaganych informacji.",
|
||||
"Please check your connection and try again.": "Sprawdź połączenie i spróbuj ponownie.",
|
||||
"Sign in to import shared books": "Zaloguj się, aby importować udostępnione książki",
|
||||
"Already in your library": "Już w Twojej bibliotece",
|
||||
"Added to your library": "Dodano do biblioteki",
|
||||
"Could not import shared book": "Nie można zaimportować udostępnionej książki",
|
||||
"Manage Shared Links": "Zarządzaj linkami udostępniania",
|
||||
"Share current page": "Udostępnij bieżącą stronę",
|
||||
"Expires in": "Wygasa za",
|
||||
"{{count}} days_one": "1 dzień",
|
||||
"{{count}} days_few": "{{count}} dni",
|
||||
"{{count}} days_many": "{{count}} dni",
|
||||
"{{count}} days_other": "{{count}} dni",
|
||||
"Expires in {{count}} days_one": "Wygasa za 1 dzień",
|
||||
"Expires in {{count}} days_few": "Wygasa za {{count}} dni",
|
||||
"Expires in {{count}} days_many": "Wygasa za {{count}} dni",
|
||||
"Expires in {{count}} days_other": "Wygasa za {{count}} dni",
|
||||
"Expires in {{count}} hours_one": "Wygasa za 1 godz.",
|
||||
"Expires in {{count}} hours_few": "Wygasa za {{count}} godz.",
|
||||
"Expires in {{count}} hours_many": "Wygasa za {{count}} godz.",
|
||||
"Expires in {{count}} hours_other": "Wygasa za {{count}} godz.",
|
||||
"Open in app": "Otwórz w aplikacji",
|
||||
"Shared with you": "Udostępnione Tobie"
|
||||
}
|
||||
|
||||
@@ -1273,5 +1273,69 @@
|
||||
"Open in Readest app": "Abrir no app Readest",
|
||||
"Continue in browser": "Continuar no navegador",
|
||||
"Don't have Readest?": "Ainda não tem o Readest?",
|
||||
"Book not in your library": "O livro não está na sua biblioteca"
|
||||
"Book not in your library": "O livro não está na sua biblioteca",
|
||||
"Share Book": "Compartilhar livro",
|
||||
"Could not create share link": "Não foi possível criar o link de compartilhamento",
|
||||
"Link copied": "Link copiado",
|
||||
"Could not copy link": "Não foi possível copiar o link",
|
||||
"Share revoked": "Compartilhamento revogado",
|
||||
"Could not revoke share": "Não foi possível revogar o compartilhamento",
|
||||
"Uploading book…": "Enviando livro…",
|
||||
"Generating…": "Gerando…",
|
||||
"Generate share link": "Gerar link de compartilhamento",
|
||||
"Includes your current page": "Inclui sua página atual",
|
||||
"Share URL": "URL de compartilhamento",
|
||||
"Copy link": "Copiar link",
|
||||
"Copied": "Copiado",
|
||||
"Share via…": "Compartilhar via…",
|
||||
"Expires {{date}}": "Expira em {{date}}",
|
||||
"Revoking…": "Revogando…",
|
||||
"Revoke share": "Revogar compartilhamento",
|
||||
"Sign in to share books": "Entre para compartilhar livros",
|
||||
"Expiring soon": "Expira em breve",
|
||||
"Missing share token": "Token de compartilhamento ausente",
|
||||
"Could not add to your library": "Não foi possível adicionar à sua biblioteca",
|
||||
"This share link is no longer available": "Este link de compartilhamento não está mais disponível",
|
||||
"The original link may have expired or been revoked.": "O link original pode ter expirado ou sido revogado.",
|
||||
"Get Readest": "Obter Readest",
|
||||
"Loading shared book…": "Carregando livro compartilhado…",
|
||||
"Adding…": "Adicionando…",
|
||||
"Add to my library": "Adicionar à minha biblioteca",
|
||||
"Could not load your shares": "Não foi possível carregar seus compartilhamentos",
|
||||
"Revoked": "Revogado",
|
||||
"Expired": "Expirado",
|
||||
"Shared books": "Livros compartilhados",
|
||||
"You haven't shared any books yet": "Você ainda não compartilhou nenhum livro",
|
||||
"Open a book and tap Share to send it to a friend.": "Abra um livro e toque em Compartilhar para enviá-lo a um amigo.",
|
||||
"{{count}} active_one": "{{count}} ativo",
|
||||
"{{count}} active_many": "{{count}} ativos",
|
||||
"{{count}} active_other": "{{count}} ativos",
|
||||
"{{count}} downloads_one": "{{count}} download",
|
||||
"{{count}} downloads_many": "{{count}} downloads",
|
||||
"{{count}} downloads_other": "{{count}} downloads",
|
||||
"starts at saved page": "começa na página salva",
|
||||
"More actions": "Mais ações",
|
||||
"Loading…": "Carregando…",
|
||||
"Load more": "Carregar mais",
|
||||
"Could not load shared book": "Não foi possível carregar o livro compartilhado",
|
||||
"The share link is missing required information.": "Faltam informações necessárias no link de compartilhamento.",
|
||||
"Please check your connection and try again.": "Verifique sua conexão e tente novamente.",
|
||||
"Sign in to import shared books": "Entre para importar livros compartilhados",
|
||||
"Already in your library": "Já está na sua biblioteca",
|
||||
"Added to your library": "Adicionado à sua biblioteca",
|
||||
"Could not import shared book": "Não foi possível importar o livro compartilhado",
|
||||
"Manage Shared Links": "Gerenciar links de compartilhamento",
|
||||
"Share current page": "Compartilhar página atual",
|
||||
"Expires in": "Expira em",
|
||||
"{{count}} days_one": "1 dia",
|
||||
"{{count}} days_many": "{{count}} dias",
|
||||
"{{count}} days_other": "{{count}} dias",
|
||||
"Expires in {{count}} days_one": "Expira em 1 dia",
|
||||
"Expires in {{count}} days_many": "Expira em {{count}} dias",
|
||||
"Expires in {{count}} days_other": "Expira em {{count}} dias",
|
||||
"Expires in {{count}} hours_one": "Expira em 1 hora",
|
||||
"Expires in {{count}} hours_many": "Expira em {{count}} horas",
|
||||
"Expires in {{count}} hours_other": "Expira em {{count}} horas",
|
||||
"Open in app": "Abrir no app",
|
||||
"Shared with you": "Compartilhado com você"
|
||||
}
|
||||
|
||||
@@ -1273,5 +1273,69 @@
|
||||
"Open in Readest app": "Deschide în aplicația Readest",
|
||||
"Continue in browser": "Continuă în browser",
|
||||
"Don't have Readest?": "Nu ai Readest?",
|
||||
"Book not in your library": "Cartea nu este în biblioteca ta"
|
||||
"Book not in your library": "Cartea nu este în biblioteca ta",
|
||||
"Share Book": "Partajează cartea",
|
||||
"Could not create share link": "Nu s-a putut crea linkul de partajare",
|
||||
"Link copied": "Link copiat",
|
||||
"Could not copy link": "Nu s-a putut copia linkul",
|
||||
"Share revoked": "Partajare revocată",
|
||||
"Could not revoke share": "Nu s-a putut revoca partajarea",
|
||||
"Uploading book…": "Se încarcă cartea…",
|
||||
"Generating…": "Se generează…",
|
||||
"Generate share link": "Generează link de partajare",
|
||||
"Includes your current page": "Include pagina ta curentă",
|
||||
"Share URL": "URL de partajare",
|
||||
"Copy link": "Copiază linkul",
|
||||
"Copied": "Copiat",
|
||||
"Share via…": "Partajează prin…",
|
||||
"Expires {{date}}": "Expiră pe {{date}}",
|
||||
"Revoking…": "Se revocă…",
|
||||
"Revoke share": "Revocă partajarea",
|
||||
"Sign in to share books": "Conectează-te pentru a partaja cărți",
|
||||
"Expiring soon": "Expiră în curând",
|
||||
"Missing share token": "Lipsește jetonul de partajare",
|
||||
"Could not add to your library": "Nu s-a putut adăuga în biblioteca ta",
|
||||
"This share link is no longer available": "Acest link de partajare nu mai este disponibil",
|
||||
"The original link may have expired or been revoked.": "Linkul original poate fi expirat sau revocat.",
|
||||
"Get Readest": "Obține Readest",
|
||||
"Loading shared book…": "Se încarcă cartea partajată…",
|
||||
"Adding…": "Se adaugă…",
|
||||
"Add to my library": "Adaugă în biblioteca mea",
|
||||
"Could not load your shares": "Nu s-au putut încărca partajările tale",
|
||||
"Revoked": "Revocat",
|
||||
"Expired": "Expirat",
|
||||
"Shared books": "Cărți partajate",
|
||||
"You haven't shared any books yet": "Nu ai partajat încă nicio carte",
|
||||
"Open a book and tap Share to send it to a friend.": "Deschide o carte și apasă pe Partajează pentru a o trimite unui prieten.",
|
||||
"{{count}} active_one": "{{count}} activ",
|
||||
"{{count}} active_few": "{{count}} active",
|
||||
"{{count}} active_other": "{{count}} de active",
|
||||
"{{count}} downloads_one": "{{count}} descărcare",
|
||||
"{{count}} downloads_few": "{{count}} descărcări",
|
||||
"{{count}} downloads_other": "{{count}} de descărcări",
|
||||
"starts at saved page": "începe de la pagina salvată",
|
||||
"More actions": "Mai multe acțiuni",
|
||||
"Loading…": "Se încarcă…",
|
||||
"Load more": "Încarcă mai multe",
|
||||
"Could not load shared book": "Nu s-a putut încărca cartea partajată",
|
||||
"The share link is missing required information.": "Lipsesc informații necesare din linkul de partajare.",
|
||||
"Please check your connection and try again.": "Verifică conexiunea și încearcă din nou.",
|
||||
"Sign in to import shared books": "Conectează-te pentru a importa cărți partajate",
|
||||
"Already in your library": "Deja în biblioteca ta",
|
||||
"Added to your library": "Adăugat în biblioteca ta",
|
||||
"Could not import shared book": "Nu s-a putut importa cartea partajată",
|
||||
"Manage Shared Links": "Gestionează linkurile de partajare",
|
||||
"Share current page": "Partajează pagina curentă",
|
||||
"Expires in": "Expiră în",
|
||||
"{{count}} days_one": "1 zi",
|
||||
"{{count}} days_few": "{{count}} zile",
|
||||
"{{count}} days_other": "{{count}} de zile",
|
||||
"Expires in {{count}} days_one": "Expiră într-o zi",
|
||||
"Expires in {{count}} days_few": "Expiră în {{count}} zile",
|
||||
"Expires in {{count}} days_other": "Expiră în {{count}} de zile",
|
||||
"Expires in {{count}} hours_one": "Expiră într-o oră",
|
||||
"Expires in {{count}} hours_few": "Expiră în {{count}} ore",
|
||||
"Expires in {{count}} hours_other": "Expiră în {{count}} de ore",
|
||||
"Open in app": "Deschide în aplicație",
|
||||
"Shared with you": "Partajat cu tine"
|
||||
}
|
||||
|
||||
@@ -1291,5 +1291,74 @@
|
||||
"Open in Readest app": "Открыть в приложении Readest",
|
||||
"Continue in browser": "Продолжить в браузере",
|
||||
"Don't have Readest?": "Ещё нет Readest?",
|
||||
"Book not in your library": "Книги нет в вашей библиотеке"
|
||||
"Book not in your library": "Книги нет в вашей библиотеке",
|
||||
"Share Book": "Поделиться книгой",
|
||||
"Could not create share link": "Не удалось создать ссылку для общего доступа",
|
||||
"Link copied": "Ссылка скопирована",
|
||||
"Could not copy link": "Не удалось скопировать ссылку",
|
||||
"Share revoked": "Доступ отозван",
|
||||
"Could not revoke share": "Не удалось отозвать доступ",
|
||||
"Uploading book…": "Загрузка книги…",
|
||||
"Generating…": "Создание…",
|
||||
"Generate share link": "Создать ссылку",
|
||||
"Includes your current page": "Включает вашу текущую страницу",
|
||||
"Share URL": "URL для общего доступа",
|
||||
"Copy link": "Копировать ссылку",
|
||||
"Copied": "Скопировано",
|
||||
"Share via…": "Поделиться через…",
|
||||
"Expires {{date}}": "Истекает {{date}}",
|
||||
"Revoking…": "Отзыв…",
|
||||
"Revoke share": "Отозвать доступ",
|
||||
"Sign in to share books": "Войдите, чтобы делиться книгами",
|
||||
"Expiring soon": "Скоро истекает",
|
||||
"Missing share token": "Отсутствует токен общего доступа",
|
||||
"Could not add to your library": "Не удалось добавить в вашу библиотеку",
|
||||
"This share link is no longer available": "Эта ссылка больше не доступна",
|
||||
"The original link may have expired or been revoked.": "Срок действия исходной ссылки мог истечь или её отозвали.",
|
||||
"Get Readest": "Скачать Readest",
|
||||
"Loading shared book…": "Загрузка книги…",
|
||||
"Adding…": "Добавление…",
|
||||
"Add to my library": "Добавить в мою библиотеку",
|
||||
"Could not load your shares": "Не удалось загрузить ваши ссылки",
|
||||
"Revoked": "Отозвано",
|
||||
"Expired": "Истекло",
|
||||
"Shared books": "Поделённые книги",
|
||||
"You haven't shared any books yet": "Вы ещё не поделились ни одной книгой",
|
||||
"Open a book and tap Share to send it to a friend.": "Откройте книгу и нажмите «Поделиться», чтобы отправить её другу.",
|
||||
"{{count}} active_one": "{{count}} активная",
|
||||
"{{count}} active_few": "{{count}} активные",
|
||||
"{{count}} active_many": "{{count}} активных",
|
||||
"{{count}} active_other": "{{count}} активных",
|
||||
"{{count}} downloads_one": "{{count}} загрузка",
|
||||
"{{count}} downloads_few": "{{count}} загрузки",
|
||||
"{{count}} downloads_many": "{{count}} загрузок",
|
||||
"{{count}} downloads_other": "{{count}} загрузок",
|
||||
"starts at saved page": "начинается с сохранённой страницы",
|
||||
"More actions": "Другие действия",
|
||||
"Loading…": "Загрузка…",
|
||||
"Load more": "Загрузить ещё",
|
||||
"Could not load shared book": "Не удалось загрузить книгу",
|
||||
"The share link is missing required information.": "В ссылке для общего доступа не хватает необходимых данных.",
|
||||
"Please check your connection and try again.": "Проверьте подключение и повторите попытку.",
|
||||
"Sign in to import shared books": "Войдите, чтобы импортировать поделённые книги",
|
||||
"Already in your library": "Уже в вашей библиотеке",
|
||||
"Added to your library": "Добавлено в вашу библиотеку",
|
||||
"Could not import shared book": "Не удалось импортировать книгу",
|
||||
"Manage Shared Links": "Управление ссылками",
|
||||
"Share current page": "Поделиться текущей страницей",
|
||||
"Expires in": "Истекает через",
|
||||
"{{count}} days_one": "{{count}} день",
|
||||
"{{count}} days_few": "{{count}} дня",
|
||||
"{{count}} days_many": "{{count}} дней",
|
||||
"{{count}} days_other": "{{count}} дней",
|
||||
"Expires in {{count}} days_one": "Истекает через {{count}} день",
|
||||
"Expires in {{count}} days_few": "Истекает через {{count}} дня",
|
||||
"Expires in {{count}} days_many": "Истекает через {{count}} дней",
|
||||
"Expires in {{count}} days_other": "Истекает через {{count}} дней",
|
||||
"Expires in {{count}} hours_one": "Истекает через {{count}} час",
|
||||
"Expires in {{count}} hours_few": "Истекает через {{count}} часа",
|
||||
"Expires in {{count}} hours_many": "Истекает через {{count}} часов",
|
||||
"Expires in {{count}} hours_other": "Истекает через {{count}} часов",
|
||||
"Open in app": "Открыть в приложении",
|
||||
"Shared with you": "Поделились с вами"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Readest යෙදුමෙහි විවෘත කරන්න",
|
||||
"Continue in browser": "බ්රවුසරයේ දිගටම යන්න",
|
||||
"Don't have Readest?": "Readest නැද්ද?",
|
||||
"Book not in your library": "පොත ඔබේ පුස්තකාලයේ නැත"
|
||||
"Book not in your library": "පොත ඔබේ පුස්තකාලයේ නැත",
|
||||
"Share Book": "පොත බෙදාගන්න",
|
||||
"Could not create share link": "බෙදාගැනීමේ සබැඳිය සෑදිය නොහැක",
|
||||
"Link copied": "සබැඳිය පිටපත් කළා",
|
||||
"Could not copy link": "සබැඳිය පිටපත් කළ නොහැක",
|
||||
"Share revoked": "බෙදාගැනීම අවලංගු කළා",
|
||||
"Could not revoke share": "බෙදාගැනීම අවලංගු කළ නොහැක",
|
||||
"Uploading book…": "පොත උඩුගත කරමින්…",
|
||||
"Generating…": "සාදමින්…",
|
||||
"Generate share link": "බෙදාගැනීමේ සබැඳිය සාදන්න",
|
||||
"Includes your current page": "ඔබගේ වර්තමාන පිටුව ඇතුළත්",
|
||||
"Share URL": "බෙදාගැනීමේ URL",
|
||||
"Copy link": "සබැඳිය පිටපත් කරන්න",
|
||||
"Copied": "පිටපත් කළා",
|
||||
"Share via…": "හරහා බෙදාගන්න…",
|
||||
"Expires {{date}}": "{{date}} දින කල් ඉකුත් වේ",
|
||||
"Revoking…": "අවලංගු කරමින්…",
|
||||
"Revoke share": "බෙදාගැනීම අවලංගු කරන්න",
|
||||
"Sign in to share books": "පොත් බෙදාගැනීමට පුරනය වන්න",
|
||||
"Expiring soon": "ඉක්මනින් කල් ඉකුත් වේ",
|
||||
"Missing share token": "බෙදාගැනීමේ ටෝකනය නොමැත",
|
||||
"Could not add to your library": "ඔබගේ පුස්තකාලයට එක් කළ නොහැක",
|
||||
"This share link is no longer available": "මෙම බෙදාගැනීමේ සබැඳිය තවදුරටත් නොමැත",
|
||||
"The original link may have expired or been revoked.": "මුල් සබැඳිය කල් ඉකුත් වී හෝ අවලංගු වී ඇත.",
|
||||
"Get Readest": "Readest ලබාගන්න",
|
||||
"Loading shared book…": "බෙදාගත් පොත පූරණය වෙමින්…",
|
||||
"Adding…": "එක් කරමින්…",
|
||||
"Add to my library": "මගේ පුස්තකාලයට එක් කරන්න",
|
||||
"Could not load your shares": "ඔබගේ බෙදාගැනීම් පූරණය කළ නොහැක",
|
||||
"Revoked": "අවලංගු",
|
||||
"Expired": "කල් ඉකුත්",
|
||||
"Shared books": "බෙදාගත් පොත්",
|
||||
"You haven't shared any books yet": "ඔබ තවම පොත් කිසිවක් බෙදාගෙන නැත",
|
||||
"Open a book and tap Share to send it to a friend.": "පොතක් විවෘත කර එය මිතුරෙකුට යවන්න බෙදාගන්න ටැප් කරන්න.",
|
||||
"{{count}} active_one": "සක්රිය {{count}}ක්",
|
||||
"{{count}} active_other": "සක්රිය {{count}}ක්",
|
||||
"{{count}} downloads_one": "බාගත {{count}}ක්",
|
||||
"{{count}} downloads_other": "බාගත {{count}}ක්",
|
||||
"starts at saved page": "සුරකින ලද පිටුවෙන් ආරම්භ වේ",
|
||||
"More actions": "තවත් ක්රියා",
|
||||
"Loading…": "පූරණය වෙමින්…",
|
||||
"Load more": "තවත් පූරණය කරන්න",
|
||||
"Could not load shared book": "බෙදාගත් පොත පූරණය කළ නොහැක",
|
||||
"The share link is missing required information.": "බෙදාගැනීමේ සබැඳියෙහි අවශ්ය තොරතුරු නොමැත.",
|
||||
"Please check your connection and try again.": "කරුණාකර ඔබගේ සම්බන්ධතාවය පරීක්ෂා කර නැවත උත්සාහ කරන්න.",
|
||||
"Sign in to import shared books": "බෙදාගත් පොත් ආයාත කිරීමට පුරනය වන්න",
|
||||
"Already in your library": "දැනටමත් ඔබගේ පුස්තකාලයේ ඇත",
|
||||
"Added to your library": "ඔබගේ පුස්තකාලයට එක් කළා",
|
||||
"Could not import shared book": "බෙදාගත් පොත ආයාත කළ නොහැක",
|
||||
"Manage Shared Links": "බෙදාගැනීමේ සබැඳි කළමනාකරණය",
|
||||
"Share current page": "වර්තමාන පිටුව බෙදාගන්න",
|
||||
"Expires in": "කල් ඉකුත් වේ",
|
||||
"{{count}} days_one": "දින 1",
|
||||
"{{count}} days_other": "දින {{count}}",
|
||||
"Expires in {{count}} days_one": "දින 1කින් කල් ඉකුත් වේ",
|
||||
"Expires in {{count}} days_other": "දින {{count}}කින් කල් ඉකුත් වේ",
|
||||
"Expires in {{count}} hours_one": "පැය 1කින් කල් ඉකුත් වේ",
|
||||
"Expires in {{count}} hours_other": "පැය {{count}}කින් කල් ඉකුත් වේ",
|
||||
"Open in app": "යෙදුමෙන් විවෘත කරන්න",
|
||||
"Shared with you": "ඔබ සමඟ බෙදාගත්"
|
||||
}
|
||||
|
||||
@@ -1291,5 +1291,74 @@
|
||||
"Open in Readest app": "Odpri v aplikaciji Readest",
|
||||
"Continue in browser": "Nadaljuj v brskalniku",
|
||||
"Don't have Readest?": "Še nimaš programa Readest?",
|
||||
"Book not in your library": "Knjiga ni v tvoji knjižnici"
|
||||
"Book not in your library": "Knjiga ni v tvoji knjižnici",
|
||||
"Share Book": "Deli knjigo",
|
||||
"Could not create share link": "Povezave za deljenje ni bilo mogoče ustvariti",
|
||||
"Link copied": "Povezava kopirana",
|
||||
"Could not copy link": "Povezave ni bilo mogoče kopirati",
|
||||
"Share revoked": "Deljenje preklicano",
|
||||
"Could not revoke share": "Deljenja ni bilo mogoče preklicati",
|
||||
"Uploading book…": "Prenašanje knjige…",
|
||||
"Generating…": "Ustvarjanje…",
|
||||
"Generate share link": "Ustvari povezavo za deljenje",
|
||||
"Includes your current page": "Vključuje vašo trenutno stran",
|
||||
"Share URL": "URL za deljenje",
|
||||
"Copy link": "Kopiraj povezavo",
|
||||
"Copied": "Kopirano",
|
||||
"Share via…": "Deli prek…",
|
||||
"Expires {{date}}": "Poteče {{date}}",
|
||||
"Revoking…": "Preklicovanje…",
|
||||
"Revoke share": "Prekliči deljenje",
|
||||
"Sign in to share books": "Prijavite se za deljenje knjig",
|
||||
"Expiring soon": "Kmalu poteče",
|
||||
"Missing share token": "Manjka žeton za deljenje",
|
||||
"Could not add to your library": "Knjige ni bilo mogoče dodati v knjižnico",
|
||||
"This share link is no longer available": "Ta povezava za deljenje ni več na voljo",
|
||||
"The original link may have expired or been revoked.": "Prvotna povezava je morda potekla ali bila preklicana.",
|
||||
"Get Readest": "Prenesi Readest",
|
||||
"Loading shared book…": "Nalaganje deljene knjige…",
|
||||
"Adding…": "Dodajanje…",
|
||||
"Add to my library": "Dodaj v mojo knjižnico",
|
||||
"Could not load your shares": "Vaših delitev ni bilo mogoče naložiti",
|
||||
"Revoked": "Preklicano",
|
||||
"Expired": "Poteklo",
|
||||
"Shared books": "Deljene knjige",
|
||||
"You haven't shared any books yet": "Še niste delili nobenih knjig",
|
||||
"Open a book and tap Share to send it to a friend.": "Odprite knjigo in tapnite Deli, da jo pošljete prijatelju.",
|
||||
"{{count}} active_one": "{{count}} aktivna",
|
||||
"{{count}} active_two": "{{count}} aktivni",
|
||||
"{{count}} active_few": "{{count}} aktivne",
|
||||
"{{count}} active_other": "{{count}} aktivnih",
|
||||
"{{count}} downloads_one": "{{count}} prenos",
|
||||
"{{count}} downloads_two": "{{count}} prenosa",
|
||||
"{{count}} downloads_few": "{{count}} prenosi",
|
||||
"{{count}} downloads_other": "{{count}} prenosov",
|
||||
"starts at saved page": "začne pri shranjeni strani",
|
||||
"More actions": "Več dejanj",
|
||||
"Loading…": "Nalaganje…",
|
||||
"Load more": "Naloži več",
|
||||
"Could not load shared book": "Deljene knjige ni bilo mogoče naložiti",
|
||||
"The share link is missing required information.": "V povezavi za deljenje manjkajo zahtevani podatki.",
|
||||
"Please check your connection and try again.": "Preverite povezavo in poskusite znova.",
|
||||
"Sign in to import shared books": "Prijavite se za uvoz deljenih knjig",
|
||||
"Already in your library": "Že je v vaši knjižnici",
|
||||
"Added to your library": "Dodano v vašo knjižnico",
|
||||
"Could not import shared book": "Deljene knjige ni bilo mogoče uvoziti",
|
||||
"Manage Shared Links": "Upravljanje povezav za deljenje",
|
||||
"Share current page": "Deli trenutno stran",
|
||||
"Expires in": "Poteče čez",
|
||||
"{{count}} days_one": "{{count}} dan",
|
||||
"{{count}} days_two": "{{count}} dneva",
|
||||
"{{count}} days_few": "{{count}} dnevi",
|
||||
"{{count}} days_other": "{{count}} dni",
|
||||
"Expires in {{count}} days_one": "Poteče čez {{count}} dan",
|
||||
"Expires in {{count}} days_two": "Poteče čez {{count}} dni",
|
||||
"Expires in {{count}} days_few": "Poteče čez {{count}} dni",
|
||||
"Expires in {{count}} days_other": "Poteče čez {{count}} dni",
|
||||
"Expires in {{count}} hours_one": "Poteče čez {{count}} uro",
|
||||
"Expires in {{count}} hours_two": "Poteče čez {{count}} uri",
|
||||
"Expires in {{count}} hours_few": "Poteče čez {{count}} ure",
|
||||
"Expires in {{count}} hours_other": "Poteče čez {{count}} ur",
|
||||
"Open in app": "Odpri v aplikaciji",
|
||||
"Shared with you": "Deljeno z vami"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Öppna i Readest-appen",
|
||||
"Continue in browser": "Fortsätt i webbläsaren",
|
||||
"Don't have Readest?": "Saknar du Readest?",
|
||||
"Book not in your library": "Boken finns inte i ditt bibliotek"
|
||||
"Book not in your library": "Boken finns inte i ditt bibliotek",
|
||||
"Share Book": "Dela bok",
|
||||
"Could not create share link": "Det gick inte att skapa delningslänken",
|
||||
"Link copied": "Länk kopierad",
|
||||
"Could not copy link": "Det gick inte att kopiera länken",
|
||||
"Share revoked": "Delning återkallad",
|
||||
"Could not revoke share": "Det gick inte att återkalla delningen",
|
||||
"Uploading book…": "Laddar upp bok…",
|
||||
"Generating…": "Genererar…",
|
||||
"Generate share link": "Skapa delningslänk",
|
||||
"Includes your current page": "Inkluderar din nuvarande sida",
|
||||
"Share URL": "Delnings-URL",
|
||||
"Copy link": "Kopiera länk",
|
||||
"Copied": "Kopierad",
|
||||
"Share via…": "Dela via…",
|
||||
"Expires {{date}}": "Upphör {{date}}",
|
||||
"Revoking…": "Återkallar…",
|
||||
"Revoke share": "Återkalla delning",
|
||||
"Sign in to share books": "Logga in för att dela böcker",
|
||||
"Expiring soon": "Upphör snart",
|
||||
"Missing share token": "Delningstoken saknas",
|
||||
"Could not add to your library": "Det gick inte att lägga till i ditt bibliotek",
|
||||
"This share link is no longer available": "Den här delningslänken är inte längre tillgänglig",
|
||||
"The original link may have expired or been revoked.": "Den ursprungliga länken kan ha löpt ut eller återkallats.",
|
||||
"Get Readest": "Hämta Readest",
|
||||
"Loading shared book…": "Laddar delad bok…",
|
||||
"Adding…": "Lägger till…",
|
||||
"Add to my library": "Lägg till i mitt bibliotek",
|
||||
"Could not load your shares": "Det gick inte att ladda dina delningar",
|
||||
"Revoked": "Återkallad",
|
||||
"Expired": "Upphörd",
|
||||
"Shared books": "Delade böcker",
|
||||
"You haven't shared any books yet": "Du har inte delat några böcker än",
|
||||
"Open a book and tap Share to send it to a friend.": "Öppna en bok och tryck på Dela för att skicka den till en vän.",
|
||||
"{{count}} active_one": "{{count}} aktiv",
|
||||
"{{count}} active_other": "{{count}} aktiva",
|
||||
"{{count}} downloads_one": "{{count}} nedladdning",
|
||||
"{{count}} downloads_other": "{{count}} nedladdningar",
|
||||
"starts at saved page": "börjar på sparad sida",
|
||||
"More actions": "Fler åtgärder",
|
||||
"Loading…": "Laddar…",
|
||||
"Load more": "Ladda mer",
|
||||
"Could not load shared book": "Det gick inte att ladda den delade boken",
|
||||
"The share link is missing required information.": "Delningslänken saknar nödvändig information.",
|
||||
"Please check your connection and try again.": "Kontrollera din anslutning och försök igen.",
|
||||
"Sign in to import shared books": "Logga in för att importera delade böcker",
|
||||
"Already in your library": "Finns redan i ditt bibliotek",
|
||||
"Added to your library": "Tillagd i ditt bibliotek",
|
||||
"Could not import shared book": "Det gick inte att importera den delade boken",
|
||||
"Manage Shared Links": "Hantera delningslänkar",
|
||||
"Share current page": "Dela nuvarande sida",
|
||||
"Expires in": "Upphör om",
|
||||
"{{count}} days_one": "1 dag",
|
||||
"{{count}} days_other": "{{count}} dagar",
|
||||
"Expires in {{count}} days_one": "Upphör om 1 dag",
|
||||
"Expires in {{count}} days_other": "Upphör om {{count}} dagar",
|
||||
"Expires in {{count}} hours_one": "Upphör om 1 timme",
|
||||
"Expires in {{count}} hours_other": "Upphör om {{count}} timmar",
|
||||
"Open in app": "Öppna i appen",
|
||||
"Shared with you": "Delat med dig"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Readest பயன்பாட்டில் திற",
|
||||
"Continue in browser": "உலாவியில் தொடரவும்",
|
||||
"Don't have Readest?": "Readest இல்லையா?",
|
||||
"Book not in your library": "புத்தகம் உங்கள் நூலகத்தில் இல்லை"
|
||||
"Book not in your library": "புத்தகம் உங்கள் நூலகத்தில் இல்லை",
|
||||
"Share Book": "புத்தகத்தைப் பகிர்",
|
||||
"Could not create share link": "பகிர்வுக் இணைப்பை உருவாக்க முடியவில்லை",
|
||||
"Link copied": "இணைப்பு நகலெடுக்கப்பட்டது",
|
||||
"Could not copy link": "இணைப்பை நகலெடுக்க முடியவில்லை",
|
||||
"Share revoked": "பகிர்வு ரத்து செய்யப்பட்டது",
|
||||
"Could not revoke share": "பகிர்வை ரத்து செய்ய முடியவில்லை",
|
||||
"Uploading book…": "புத்தகத்தை பதிவேற்றுகிறது…",
|
||||
"Generating…": "உருவாக்குகிறது…",
|
||||
"Generate share link": "பகிர்வு இணைப்பை உருவாக்கு",
|
||||
"Includes your current page": "உங்கள் தற்போதைய பக்கத்தை உள்ளடக்கியது",
|
||||
"Share URL": "பகிர்வு URL",
|
||||
"Copy link": "இணைப்பை நகலெடு",
|
||||
"Copied": "நகலெடுக்கப்பட்டது",
|
||||
"Share via…": "மூலம் பகிர்…",
|
||||
"Expires {{date}}": "{{date}} அன்று காலாவதியாகும்",
|
||||
"Revoking…": "ரத்து செய்கிறது…",
|
||||
"Revoke share": "பகிர்வை ரத்து செய்",
|
||||
"Sign in to share books": "புத்தகங்களைப் பகிர உள்நுழையவும்",
|
||||
"Expiring soon": "விரைவில் காலாவதியாகும்",
|
||||
"Missing share token": "பகிர்வு டோக்கன் இல்லை",
|
||||
"Could not add to your library": "உங்கள் நூலகத்தில் சேர்க்க முடியவில்லை",
|
||||
"This share link is no longer available": "இந்தப் பகிர்வு இணைப்பு இனி கிடைக்காது",
|
||||
"The original link may have expired or been revoked.": "அசல் இணைப்பு காலாவதியாகியிருக்கலாம் அல்லது ரத்து செய்யப்பட்டிருக்கலாம்.",
|
||||
"Get Readest": "Readest பெறுக",
|
||||
"Loading shared book…": "பகிரப்பட்ட புத்தகத்தை ஏற்றுகிறது…",
|
||||
"Adding…": "சேர்க்கிறது…",
|
||||
"Add to my library": "எனது நூலகத்தில் சேர்",
|
||||
"Could not load your shares": "உங்கள் பகிர்வுகளை ஏற்ற முடியவில்லை",
|
||||
"Revoked": "ரத்து செய்யப்பட்டது",
|
||||
"Expired": "காலாவதியானது",
|
||||
"Shared books": "பகிரப்பட்ட புத்தகங்கள்",
|
||||
"You haven't shared any books yet": "நீங்கள் இதுவரை எந்தப் புத்தகத்தையும் பகிரவில்லை",
|
||||
"Open a book and tap Share to send it to a friend.": "புத்தகத்தைத் திறந்து நண்பருக்கு அனுப்ப பகிர் என்பதைத் தட்டவும்.",
|
||||
"{{count}} active_one": "{{count}} செயலில்",
|
||||
"{{count}} active_other": "{{count}} செயலில்",
|
||||
"{{count}} downloads_one": "{{count}} பதிவிறக்கம்",
|
||||
"{{count}} downloads_other": "{{count}} பதிவிறக்கங்கள்",
|
||||
"starts at saved page": "சேமிக்கப்பட்ட பக்கத்திலிருந்து தொடங்குகிறது",
|
||||
"More actions": "மேலும் செயல்கள்",
|
||||
"Loading…": "ஏற்றுகிறது…",
|
||||
"Load more": "மேலும் ஏற்று",
|
||||
"Could not load shared book": "பகிரப்பட்ட புத்தகத்தை ஏற்ற முடியவில்லை",
|
||||
"The share link is missing required information.": "பகிர்வு இணைப்பில் தேவையான தகவல் இல்லை.",
|
||||
"Please check your connection and try again.": "உங்கள் இணைப்பை சரிபார்த்து மீண்டும் முயற்சிக்கவும்.",
|
||||
"Sign in to import shared books": "பகிரப்பட்ட புத்தகங்களை இறக்குமதி செய்ய உள்நுழையவும்",
|
||||
"Already in your library": "ஏற்கனவே உங்கள் நூலகத்தில் உள்ளது",
|
||||
"Added to your library": "உங்கள் நூலகத்தில் சேர்க்கப்பட்டது",
|
||||
"Could not import shared book": "பகிரப்பட்ட புத்தகத்தை இறக்குமதி செய்ய முடியவில்லை",
|
||||
"Manage Shared Links": "பகிர்வு இணைப்புகளை நிர்வகி",
|
||||
"Share current page": "தற்போதைய பக்கத்தைப் பகிர்",
|
||||
"Expires in": "காலாவதியாகும்",
|
||||
"{{count}} days_one": "1 நாள்",
|
||||
"{{count}} days_other": "{{count}} நாட்கள்",
|
||||
"Expires in {{count}} days_one": "1 நாளில் காலாவதியாகும்",
|
||||
"Expires in {{count}} days_other": "{{count}} நாட்களில் காலாவதியாகும்",
|
||||
"Expires in {{count}} hours_one": "1 மணிநேரத்தில் காலாவதியாகும்",
|
||||
"Expires in {{count}} hours_other": "{{count}} மணிநேரத்தில் காலாவதியாகும்",
|
||||
"Open in app": "பயன்பாட்டில் திற",
|
||||
"Shared with you": "உங்களுடன் பகிரப்பட்டது"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "เปิดในแอป Readest",
|
||||
"Continue in browser": "ทำต่อในเบราว์เซอร์",
|
||||
"Don't have Readest?": "ยังไม่มี Readest?",
|
||||
"Book not in your library": "ไม่พบหนังสือนี้ในคลังของคุณ"
|
||||
"Book not in your library": "ไม่พบหนังสือนี้ในคลังของคุณ",
|
||||
"Share Book": "แชร์หนังสือ",
|
||||
"Could not create share link": "ไม่สามารถสร้างลิงก์แชร์ได้",
|
||||
"Link copied": "คัดลอกลิงก์แล้ว",
|
||||
"Could not copy link": "ไม่สามารถคัดลอกลิงก์ได้",
|
||||
"Share revoked": "ยกเลิกการแชร์แล้ว",
|
||||
"Could not revoke share": "ไม่สามารถยกเลิกการแชร์ได้",
|
||||
"Uploading book…": "กำลังอัปโหลดหนังสือ…",
|
||||
"Generating…": "กำลังสร้าง…",
|
||||
"Generate share link": "สร้างลิงก์แชร์",
|
||||
"Includes your current page": "รวมหน้าปัจจุบันของคุณ",
|
||||
"Share URL": "URL แชร์",
|
||||
"Copy link": "คัดลอกลิงก์",
|
||||
"Copied": "คัดลอกแล้ว",
|
||||
"Share via…": "แชร์ผ่าน…",
|
||||
"Expires {{date}}": "หมดอายุ {{date}}",
|
||||
"Revoking…": "กำลังยกเลิก…",
|
||||
"Revoke share": "ยกเลิกการแชร์",
|
||||
"Sign in to share books": "เข้าสู่ระบบเพื่อแชร์หนังสือ",
|
||||
"Expiring soon": "จะหมดอายุเร็วๆ นี้",
|
||||
"Missing share token": "ไม่มีโทเค็นแชร์",
|
||||
"Could not add to your library": "ไม่สามารถเพิ่มลงในคลังของคุณได้",
|
||||
"This share link is no longer available": "ลิงก์แชร์นี้ไม่สามารถใช้งานได้อีก",
|
||||
"The original link may have expired or been revoked.": "ลิงก์ต้นฉบับอาจหมดอายุหรือถูกยกเลิกแล้ว",
|
||||
"Get Readest": "รับ Readest",
|
||||
"Loading shared book…": "กำลังโหลดหนังสือที่แชร์…",
|
||||
"Adding…": "กำลังเพิ่ม…",
|
||||
"Add to my library": "เพิ่มในคลังของฉัน",
|
||||
"Could not load your shares": "ไม่สามารถโหลดการแชร์ของคุณได้",
|
||||
"Revoked": "ยกเลิกแล้ว",
|
||||
"Expired": "หมดอายุ",
|
||||
"Shared books": "หนังสือที่แชร์",
|
||||
"You haven't shared any books yet": "คุณยังไม่ได้แชร์หนังสือใดๆ",
|
||||
"Open a book and tap Share to send it to a friend.": "เปิดหนังสือแล้วแตะ แชร์ เพื่อส่งให้เพื่อน",
|
||||
"{{count}} active_other": "ใช้งานอยู่ {{count}} รายการ",
|
||||
"{{count}} downloads_other": "ดาวน์โหลด {{count}} ครั้ง",
|
||||
"starts at saved page": "เริ่มที่หน้าที่บันทึกไว้",
|
||||
"More actions": "การดำเนินการเพิ่มเติม",
|
||||
"Loading…": "กำลังโหลด…",
|
||||
"Load more": "โหลดเพิ่ม",
|
||||
"Could not load shared book": "ไม่สามารถโหลดหนังสือที่แชร์ได้",
|
||||
"The share link is missing required information.": "ลิงก์แชร์ขาดข้อมูลที่จำเป็น",
|
||||
"Please check your connection and try again.": "โปรดตรวจสอบการเชื่อมต่อของคุณและลองอีกครั้ง",
|
||||
"Sign in to import shared books": "เข้าสู่ระบบเพื่อนำเข้าหนังสือที่แชร์",
|
||||
"Already in your library": "อยู่ในคลังของคุณแล้ว",
|
||||
"Added to your library": "เพิ่มลงในคลังของคุณแล้ว",
|
||||
"Could not import shared book": "ไม่สามารถนำเข้าหนังสือที่แชร์ได้",
|
||||
"Manage Shared Links": "จัดการลิงก์แชร์",
|
||||
"Share current page": "แชร์หน้าปัจจุบัน",
|
||||
"Expires in": "หมดอายุใน",
|
||||
"{{count}} days_other": "{{count}} วัน",
|
||||
"Expires in {{count}} days_other": "หมดอายุใน {{count}} วัน",
|
||||
"Expires in {{count}} hours_other": "หมดอายุใน {{count}} ชั่วโมง",
|
||||
"Open in app": "เปิดในแอป",
|
||||
"Shared with you": "แชร์กับคุณ"
|
||||
}
|
||||
|
||||
@@ -1255,5 +1255,64 @@
|
||||
"Open in Readest app": "Readest uygulamasında aç",
|
||||
"Continue in browser": "Tarayıcıda devam et",
|
||||
"Don't have Readest?": "Readest'iniz yok mu?",
|
||||
"Book not in your library": "Kitap, kitaplığınızda yok"
|
||||
"Book not in your library": "Kitap, kitaplığınızda yok",
|
||||
"Share Book": "Kitabı paylaş",
|
||||
"Could not create share link": "Paylaşım bağlantısı oluşturulamadı",
|
||||
"Link copied": "Bağlantı kopyalandı",
|
||||
"Could not copy link": "Bağlantı kopyalanamadı",
|
||||
"Share revoked": "Paylaşım iptal edildi",
|
||||
"Could not revoke share": "Paylaşım iptal edilemedi",
|
||||
"Uploading book…": "Kitap yükleniyor…",
|
||||
"Generating…": "Oluşturuluyor…",
|
||||
"Generate share link": "Paylaşım bağlantısı oluştur",
|
||||
"Includes your current page": "Mevcut sayfanızı içerir",
|
||||
"Share URL": "Paylaşım URL’si",
|
||||
"Copy link": "Bağlantıyı kopyala",
|
||||
"Copied": "Kopyalandı",
|
||||
"Share via…": "Şununla paylaş…",
|
||||
"Expires {{date}}": "{{date}} tarihinde sona erer",
|
||||
"Revoking…": "İptal ediliyor…",
|
||||
"Revoke share": "Paylaşımı iptal et",
|
||||
"Sign in to share books": "Kitapları paylaşmak için oturum açın",
|
||||
"Expiring soon": "Yakında sona eriyor",
|
||||
"Missing share token": "Paylaşım belirteci eksik",
|
||||
"Could not add to your library": "Kütüphanenize eklenemedi",
|
||||
"This share link is no longer available": "Bu paylaşım bağlantısı artık kullanılamıyor",
|
||||
"The original link may have expired or been revoked.": "Orijinal bağlantının süresi dolmuş veya iptal edilmiş olabilir.",
|
||||
"Get Readest": "Readest'i edinin",
|
||||
"Loading shared book…": "Paylaşılan kitap yükleniyor…",
|
||||
"Adding…": "Ekleniyor…",
|
||||
"Add to my library": "Kütüphaneme ekle",
|
||||
"Could not load your shares": "Paylaşımlarınız yüklenemedi",
|
||||
"Revoked": "İptal edildi",
|
||||
"Expired": "Süresi doldu",
|
||||
"Shared books": "Paylaşılan kitaplar",
|
||||
"You haven't shared any books yet": "Henüz hiç kitap paylaşmadınız",
|
||||
"Open a book and tap Share to send it to a friend.": "Bir kitap açın ve arkadaşınıza göndermek için Paylaş’a dokunun.",
|
||||
"{{count}} active_one": "{{count}} etkin",
|
||||
"{{count}} active_other": "{{count}} etkin",
|
||||
"{{count}} downloads_one": "{{count}} indirme",
|
||||
"{{count}} downloads_other": "{{count}} indirme",
|
||||
"starts at saved page": "kayıtlı sayfadan başlar",
|
||||
"More actions": "Diğer işlemler",
|
||||
"Loading…": "Yükleniyor…",
|
||||
"Load more": "Daha fazla yükle",
|
||||
"Could not load shared book": "Paylaşılan kitap yüklenemedi",
|
||||
"The share link is missing required information.": "Paylaşım bağlantısında gerekli bilgiler eksik.",
|
||||
"Please check your connection and try again.": "Bağlantınızı kontrol edip tekrar deneyin.",
|
||||
"Sign in to import shared books": "Paylaşılan kitapları içe aktarmak için oturum açın",
|
||||
"Already in your library": "Zaten kütüphanenizde",
|
||||
"Added to your library": "Kütüphanenize eklendi",
|
||||
"Could not import shared book": "Paylaşılan kitap içe aktarılamadı",
|
||||
"Manage Shared Links": "Paylaşım bağlantılarını yönet",
|
||||
"Share current page": "Mevcut sayfayı paylaş",
|
||||
"Expires in": "Sona erer",
|
||||
"{{count}} days_one": "1 gün",
|
||||
"{{count}} days_other": "{{count}} gün",
|
||||
"Expires in {{count}} days_one": "1 gün içinde sona erer",
|
||||
"Expires in {{count}} days_other": "{{count}} gün içinde sona erer",
|
||||
"Expires in {{count}} hours_one": "1 saat içinde sona erer",
|
||||
"Expires in {{count}} hours_other": "{{count}} saat içinde sona erer",
|
||||
"Open in app": "Uygulamada aç",
|
||||
"Shared with you": "Sizinle paylaşıldı"
|
||||
}
|
||||
|
||||
@@ -1291,5 +1291,74 @@
|
||||
"Open in Readest app": "Відкрити в застосунку Readest",
|
||||
"Continue in browser": "Продовжити у браузері",
|
||||
"Don't have Readest?": "Не маєте Readest?",
|
||||
"Book not in your library": "Книги немає у вашій бібліотеці"
|
||||
"Book not in your library": "Книги немає у вашій бібліотеці",
|
||||
"Share Book": "Поділитися книгою",
|
||||
"Could not create share link": "Не вдалося створити посилання для поширення",
|
||||
"Link copied": "Посилання скопійовано",
|
||||
"Could not copy link": "Не вдалося скопіювати посилання",
|
||||
"Share revoked": "Доступ скасовано",
|
||||
"Could not revoke share": "Не вдалося скасувати доступ",
|
||||
"Uploading book…": "Завантаження книги…",
|
||||
"Generating…": "Створення…",
|
||||
"Generate share link": "Створити посилання",
|
||||
"Includes your current page": "Включає вашу поточну сторінку",
|
||||
"Share URL": "URL для поширення",
|
||||
"Copy link": "Копіювати посилання",
|
||||
"Copied": "Скопійовано",
|
||||
"Share via…": "Поділитися через…",
|
||||
"Expires {{date}}": "Дійсна до {{date}}",
|
||||
"Revoking…": "Скасування…",
|
||||
"Revoke share": "Скасувати доступ",
|
||||
"Sign in to share books": "Увійдіть, щоб ділитися книгами",
|
||||
"Expiring soon": "Скоро закінчиться",
|
||||
"Missing share token": "Відсутній маркер доступу",
|
||||
"Could not add to your library": "Не вдалося додати до вашої бібліотеки",
|
||||
"This share link is no longer available": "Це посилання більше не доступне",
|
||||
"The original link may have expired or been revoked.": "Первинне посилання могло втратити чинність або бути скасоване.",
|
||||
"Get Readest": "Отримати Readest",
|
||||
"Loading shared book…": "Завантаження поширеної книги…",
|
||||
"Adding…": "Додавання…",
|
||||
"Add to my library": "Додати до моєї бібліотеки",
|
||||
"Could not load your shares": "Не вдалося завантажити ваші посилання",
|
||||
"Revoked": "Скасовано",
|
||||
"Expired": "Минув термін",
|
||||
"Shared books": "Поширені книги",
|
||||
"You haven't shared any books yet": "Ви ще не поділилися жодною книгою",
|
||||
"Open a book and tap Share to send it to a friend.": "Відкрийте книгу й торкніться «Поділитися», щоб надіслати її другові.",
|
||||
"{{count}} active_one": "{{count}} активне",
|
||||
"{{count}} active_few": "{{count}} активні",
|
||||
"{{count}} active_many": "{{count}} активних",
|
||||
"{{count}} active_other": "{{count}} активних",
|
||||
"{{count}} downloads_one": "{{count}} завантаження",
|
||||
"{{count}} downloads_few": "{{count}} завантаження",
|
||||
"{{count}} downloads_many": "{{count}} завантажень",
|
||||
"{{count}} downloads_other": "{{count}} завантажень",
|
||||
"starts at saved page": "починається зі збереженої сторінки",
|
||||
"More actions": "Більше дій",
|
||||
"Loading…": "Завантаження…",
|
||||
"Load more": "Завантажити більше",
|
||||
"Could not load shared book": "Не вдалося завантажити поширену книгу",
|
||||
"The share link is missing required information.": "У посиланні бракує необхідної інформації.",
|
||||
"Please check your connection and try again.": "Перевірте підключення та спробуйте ще раз.",
|
||||
"Sign in to import shared books": "Увійдіть, щоб імпортувати поширені книги",
|
||||
"Already in your library": "Уже у вашій бібліотеці",
|
||||
"Added to your library": "Додано до вашої бібліотеки",
|
||||
"Could not import shared book": "Не вдалося імпортувати поширену книгу",
|
||||
"Manage Shared Links": "Керування посиланнями",
|
||||
"Share current page": "Поділитися поточною сторінкою",
|
||||
"Expires in": "Закінчується через",
|
||||
"{{count}} days_one": "{{count}} день",
|
||||
"{{count}} days_few": "{{count}} дні",
|
||||
"{{count}} days_many": "{{count}} днів",
|
||||
"{{count}} days_other": "{{count}} днів",
|
||||
"Expires in {{count}} days_one": "Дійсне ще {{count}} день",
|
||||
"Expires in {{count}} days_few": "Дійсне ще {{count}} дні",
|
||||
"Expires in {{count}} days_many": "Дійсне ще {{count}} днів",
|
||||
"Expires in {{count}} days_other": "Дійсне ще {{count}} днів",
|
||||
"Expires in {{count}} hours_one": "Дійсне ще {{count}} годину",
|
||||
"Expires in {{count}} hours_few": "Дійсне ще {{count}} години",
|
||||
"Expires in {{count}} hours_many": "Дійсне ще {{count}} годин",
|
||||
"Expires in {{count}} hours_other": "Дійсне ще {{count}} годин",
|
||||
"Open in app": "Відкрити в застосунку",
|
||||
"Shared with you": "Поділилися з вами"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "Mở trong ứng dụng Readest",
|
||||
"Continue in browser": "Tiếp tục trong trình duyệt",
|
||||
"Don't have Readest?": "Bạn chưa có Readest?",
|
||||
"Book not in your library": "Sách không có trong thư viện của bạn"
|
||||
"Book not in your library": "Sách không có trong thư viện của bạn",
|
||||
"Share Book": "Chia sẻ sách",
|
||||
"Could not create share link": "Không thể tạo liên kết chia sẻ",
|
||||
"Link copied": "Đã sao chép liên kết",
|
||||
"Could not copy link": "Không thể sao chép liên kết",
|
||||
"Share revoked": "Đã thu hồi chia sẻ",
|
||||
"Could not revoke share": "Không thể thu hồi chia sẻ",
|
||||
"Uploading book…": "Đang tải lên sách…",
|
||||
"Generating…": "Đang tạo…",
|
||||
"Generate share link": "Tạo liên kết chia sẻ",
|
||||
"Includes your current page": "Bao gồm trang hiện tại của bạn",
|
||||
"Share URL": "URL chia sẻ",
|
||||
"Copy link": "Sao chép liên kết",
|
||||
"Copied": "Đã sao chép",
|
||||
"Share via…": "Chia sẻ qua…",
|
||||
"Expires {{date}}": "Hết hạn vào {{date}}",
|
||||
"Revoking…": "Đang thu hồi…",
|
||||
"Revoke share": "Thu hồi chia sẻ",
|
||||
"Sign in to share books": "Đăng nhập để chia sẻ sách",
|
||||
"Expiring soon": "Sắp hết hạn",
|
||||
"Missing share token": "Thiếu mã chia sẻ",
|
||||
"Could not add to your library": "Không thể thêm vào thư viện của bạn",
|
||||
"This share link is no longer available": "Liên kết chia sẻ này không còn khả dụng",
|
||||
"The original link may have expired or been revoked.": "Liên kết gốc có thể đã hết hạn hoặc bị thu hồi.",
|
||||
"Get Readest": "Tải Readest",
|
||||
"Loading shared book…": "Đang tải sách được chia sẻ…",
|
||||
"Adding…": "Đang thêm…",
|
||||
"Add to my library": "Thêm vào thư viện của tôi",
|
||||
"Could not load your shares": "Không thể tải danh sách chia sẻ của bạn",
|
||||
"Revoked": "Đã thu hồi",
|
||||
"Expired": "Đã hết hạn",
|
||||
"Shared books": "Sách đã chia sẻ",
|
||||
"You haven't shared any books yet": "Bạn chưa chia sẻ cuốn sách nào",
|
||||
"Open a book and tap Share to send it to a friend.": "Mở một cuốn sách và nhấn Chia sẻ để gửi cho bạn bè.",
|
||||
"{{count}} active_other": "{{count}} đang hoạt động",
|
||||
"{{count}} downloads_other": "{{count}} lượt tải",
|
||||
"starts at saved page": "bắt đầu tại trang đã lưu",
|
||||
"More actions": "Thao tác khác",
|
||||
"Loading…": "Đang tải…",
|
||||
"Load more": "Tải thêm",
|
||||
"Could not load shared book": "Không thể tải sách được chia sẻ",
|
||||
"The share link is missing required information.": "Liên kết chia sẻ thiếu thông tin cần thiết.",
|
||||
"Please check your connection and try again.": "Vui lòng kiểm tra kết nối và thử lại.",
|
||||
"Sign in to import shared books": "Đăng nhập để nhập sách được chia sẻ",
|
||||
"Already in your library": "Đã có trong thư viện của bạn",
|
||||
"Added to your library": "Đã thêm vào thư viện của bạn",
|
||||
"Could not import shared book": "Không thể nhập sách được chia sẻ",
|
||||
"Manage Shared Links": "Quản lý liên kết chia sẻ",
|
||||
"Share current page": "Chia sẻ trang hiện tại",
|
||||
"Expires in": "Hết hạn sau",
|
||||
"{{count}} days_other": "{{count}} ngày",
|
||||
"Expires in {{count}} days_other": "Hết hạn sau {{count}} ngày",
|
||||
"Expires in {{count}} hours_other": "Hết hạn sau {{count}} giờ",
|
||||
"Open in app": "Mở trong ứng dụng",
|
||||
"Shared with you": "Được chia sẻ với bạn"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "在 Readest 应用中打开",
|
||||
"Continue in browser": "在浏览器中继续",
|
||||
"Don't have Readest?": "还没有 Readest?",
|
||||
"Book not in your library": "书库中没有这本书"
|
||||
"Book not in your library": "书库中没有这本书",
|
||||
"Share Book": "分享书籍",
|
||||
"Could not create share link": "无法创建分享链接",
|
||||
"Link copied": "链接已复制",
|
||||
"Could not copy link": "无法复制链接",
|
||||
"Share revoked": "已撤销分享",
|
||||
"Could not revoke share": "无法撤销分享",
|
||||
"Uploading book…": "正在上传书籍…",
|
||||
"Generating…": "正在生成…",
|
||||
"Generate share link": "生成分享链接",
|
||||
"Includes your current page": "包含你当前的页面",
|
||||
"Share URL": "分享 URL",
|
||||
"Copy link": "复制链接",
|
||||
"Copied": "已复制",
|
||||
"Share via…": "通过…分享",
|
||||
"Expires {{date}}": "到期日 {{date}}",
|
||||
"Revoking…": "正在撤销…",
|
||||
"Revoke share": "撤销分享",
|
||||
"Sign in to share books": "登录以分享书籍",
|
||||
"Expiring soon": "即将到期",
|
||||
"Missing share token": "缺少分享令牌",
|
||||
"Could not add to your library": "无法添加到你的书库",
|
||||
"This share link is no longer available": "此分享链接已不可用",
|
||||
"The original link may have expired or been revoked.": "原始链接可能已过期或被撤销。",
|
||||
"Get Readest": "获取 Readest",
|
||||
"Loading shared book…": "正在加载共享书籍…",
|
||||
"Adding…": "正在添加…",
|
||||
"Add to my library": "加入我的书库",
|
||||
"Could not load your shares": "无法加载你的分享",
|
||||
"Revoked": "已撤销",
|
||||
"Expired": "已过期",
|
||||
"Shared books": "已分享的书籍",
|
||||
"You haven't shared any books yet": "你还没有分享过任何书籍",
|
||||
"Open a book and tap Share to send it to a friend.": "打开一本书,点按\"分享\"发送给朋友。",
|
||||
"{{count}} active_other": "{{count}} 个活动中",
|
||||
"{{count}} downloads_other": "{{count}} 次下载",
|
||||
"starts at saved page": "从保存的页面开始",
|
||||
"More actions": "更多操作",
|
||||
"Loading…": "加载中…",
|
||||
"Load more": "加载更多",
|
||||
"Could not load shared book": "无法加载共享书籍",
|
||||
"The share link is missing required information.": "分享链接缺少必要信息。",
|
||||
"Please check your connection and try again.": "请检查你的网络连接后重试。",
|
||||
"Sign in to import shared books": "登录以导入共享书籍",
|
||||
"Already in your library": "已在你的书库中",
|
||||
"Added to your library": "已加入你的书库",
|
||||
"Could not import shared book": "无法导入共享书籍",
|
||||
"Manage Shared Links": "管理分享链接",
|
||||
"Share current page": "分享当前页",
|
||||
"Expires in": "有效期",
|
||||
"{{count}} days_other": "{{count}} 天",
|
||||
"Expires in {{count}} days_other": "{{count}} 天后到期",
|
||||
"Expires in {{count}} hours_other": "{{count}} 小时后到期",
|
||||
"Open in app": "在应用中打开",
|
||||
"Shared with you": "与你分享"
|
||||
}
|
||||
|
||||
@@ -1237,5 +1237,59 @@
|
||||
"Open in Readest app": "在 Readest 應用程式中開啟",
|
||||
"Continue in browser": "在瀏覽器中繼續",
|
||||
"Don't have Readest?": "還沒有 Readest?",
|
||||
"Book not in your library": "書庫中沒有這本書"
|
||||
"Book not in your library": "書庫中沒有這本書",
|
||||
"Share Book": "分享書籍",
|
||||
"Could not create share link": "無法建立分享連結",
|
||||
"Link copied": "已複製連結",
|
||||
"Could not copy link": "無法複製連結",
|
||||
"Share revoked": "已撤銷分享",
|
||||
"Could not revoke share": "無法撤銷分享",
|
||||
"Uploading book…": "正在上傳書籍…",
|
||||
"Generating…": "正在產生…",
|
||||
"Generate share link": "產生分享連結",
|
||||
"Includes your current page": "包含您目前的頁面",
|
||||
"Share URL": "分享 URL",
|
||||
"Copy link": "複製連結",
|
||||
"Copied": "已複製",
|
||||
"Share via…": "透過…分享",
|
||||
"Expires {{date}}": "到期日 {{date}}",
|
||||
"Revoking…": "正在撤銷…",
|
||||
"Revoke share": "撤銷分享",
|
||||
"Sign in to share books": "登入以分享書籍",
|
||||
"Expiring soon": "即將到期",
|
||||
"Missing share token": "缺少分享權杖",
|
||||
"Could not add to your library": "無法加入您的書庫",
|
||||
"This share link is no longer available": "此分享連結已不可用",
|
||||
"The original link may have expired or been revoked.": "原始連結可能已過期或被撤銷。",
|
||||
"Get Readest": "取得 Readest",
|
||||
"Loading shared book…": "正在載入分享書籍…",
|
||||
"Adding…": "正在新增…",
|
||||
"Add to my library": "加入我的書庫",
|
||||
"Could not load your shares": "無法載入您的分享",
|
||||
"Revoked": "已撤銷",
|
||||
"Expired": "已過期",
|
||||
"Shared books": "已分享的書籍",
|
||||
"You haven't shared any books yet": "您尚未分享任何書籍",
|
||||
"Open a book and tap Share to send it to a friend.": "開啟一本書,點按「分享」傳送給朋友。",
|
||||
"{{count}} active_other": "{{count}} 個有效",
|
||||
"{{count}} downloads_other": "{{count}} 次下載",
|
||||
"starts at saved page": "從保存的頁面開始",
|
||||
"More actions": "更多操作",
|
||||
"Loading…": "載入中…",
|
||||
"Load more": "載入更多",
|
||||
"Could not load shared book": "無法載入分享書籍",
|
||||
"The share link is missing required information.": "分享連結缺少必要資訊。",
|
||||
"Please check your connection and try again.": "請檢查您的網路連線後再試一次。",
|
||||
"Sign in to import shared books": "登入以匯入分享書籍",
|
||||
"Already in your library": "已在您的書庫中",
|
||||
"Added to your library": "已加入您的書庫",
|
||||
"Could not import shared book": "無法匯入分享書籍",
|
||||
"Manage Shared Links": "管理分享連結",
|
||||
"Share current page": "分享目前頁面",
|
||||
"Expires in": "有效期",
|
||||
"{{count}} days_other": "{{count}} 天",
|
||||
"Expires in {{count}} days_other": "{{count}} 天後到期",
|
||||
"Expires in {{count}} hours_other": "{{count}} 小時後到期",
|
||||
"Open in app": "在應用中開啟",
|
||||
"Shared with you": "與您分享"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateShareToken, hashShareToken, isValidShareToken } from '@/libs/share-server';
|
||||
|
||||
describe('share-server', () => {
|
||||
describe('generateShareToken', () => {
|
||||
it('produces a 22-char alphanumeric raw token', async () => {
|
||||
const { raw, hash } = await generateShareToken();
|
||||
expect(raw).toMatch(/^[A-Za-z0-9]{22}$/);
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('produces unique tokens across calls', async () => {
|
||||
const tokens = new Set<string>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const { raw } = await generateShareToken();
|
||||
tokens.add(raw);
|
||||
}
|
||||
expect(tokens.size).toBe(50);
|
||||
});
|
||||
|
||||
it('hash is deterministic for the same raw input', async () => {
|
||||
const { raw, hash } = await generateShareToken();
|
||||
const again = await hashShareToken(raw);
|
||||
expect(again).toBe(hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidShareToken', () => {
|
||||
it('accepts well-formed 22-char alphanumeric tokens', () => {
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTuV')).toBe(true);
|
||||
expect(isValidShareToken('0123456789abcdefABCDEF')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong-length tokens', () => {
|
||||
expect(isValidShareToken('short')).toBe(false);
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTuVextra')).toBe(false);
|
||||
expect(isValidShareToken('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects tokens with non-alphanumeric characters', () => {
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu-')).toBe(false);
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu_')).toBe(false);
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu.')).toBe(false);
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRs Tuv')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-string input', () => {
|
||||
expect(isValidShareToken(undefined)).toBe(false);
|
||||
expect(isValidShareToken(null)).toBe(false);
|
||||
expect(isValidShareToken(42)).toBe(false);
|
||||
expect(isValidShareToken({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashShareToken', () => {
|
||||
it('produces a 64-char lowercase hex SHA-256 digest', async () => {
|
||||
const hash = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuV');
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('different inputs produce different hashes', async () => {
|
||||
const a = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuV');
|
||||
const b = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuW');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('matches a known SHA-256 vector for sanity', async () => {
|
||||
// Known test vector: sha256("abc") = ba7816bf...
|
||||
const hash = await hashShareToken('abc');
|
||||
expect(hash).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildShareUrl, parseShareDeepLink } from '@/utils/share';
|
||||
|
||||
describe('buildShareUrl', () => {
|
||||
it('builds the canonical https URL for a token', () => {
|
||||
expect(buildShareUrl('aBcDeFgHiJkLmNoPqRsTuV')).toBe(
|
||||
'https://web.readest.com/s/aBcDeFgHiJkLmNoPqRsTuV',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseShareDeepLink', () => {
|
||||
const VALID_TOKEN = 'aBcDeFgHiJkLmNoPqRsTuV';
|
||||
|
||||
it('parses readest://share/{token}', () => {
|
||||
expect(parseShareDeepLink(`readest://share/${VALID_TOKEN}`)).toEqual({ token: VALID_TOKEN });
|
||||
});
|
||||
|
||||
it('parses https://web.readest.com/s/{token}', () => {
|
||||
expect(parseShareDeepLink(`https://web.readest.com/s/${VALID_TOKEN}`)).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses *.readest.com subdomains for preview deploys', () => {
|
||||
expect(parseShareDeepLink(`https://staging.readest.com/s/${VALID_TOKEN}`)).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects tokens of the wrong length', () => {
|
||||
expect(parseShareDeepLink('readest://share/short')).toBeNull();
|
||||
expect(parseShareDeepLink(`readest://share/${VALID_TOKEN}extra`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects tokens with disallowed characters', () => {
|
||||
// Underscore and hyphen are explicitly NOT in the alphabet.
|
||||
const bad = 'aBcDeFgHiJkLmNoPqRsTu-';
|
||||
expect(parseShareDeepLink(`readest://share/${bad}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects URLs from third-party hosts', () => {
|
||||
expect(parseShareDeepLink(`https://evil.example.com/s/${VALID_TOKEN}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects readest:// URLs whose host is not "share"', () => {
|
||||
expect(parseShareDeepLink(`readest://book/${VALID_TOKEN}`)).toBeNull();
|
||||
expect(parseShareDeepLink(`readest://annotation/${VALID_TOKEN}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects nested or extra path segments', () => {
|
||||
expect(parseShareDeepLink(`https://web.readest.com/s/${VALID_TOKEN}/extra`)).toBeNull();
|
||||
expect(parseShareDeepLink(`https://web.readest.com/extra/s/${VALID_TOKEN}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed input', () => {
|
||||
expect(parseShareDeepLink('')).toBeNull();
|
||||
expect(parseShareDeepLink('not-a-url')).toBeNull();
|
||||
expect(parseShareDeepLink('ftp://web.readest.com/s/' + VALID_TOKEN)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDownloadSignedUrl } from '@/utils/object';
|
||||
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
|
||||
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
// GET /api/share/[token]/cover — public 302 redirect to a presigned cover URL.
|
||||
// Cached briefly so chat-app preview crawlers don't re-fetch the same image
|
||||
// for every recipient. Covers aren't sensitive; max-age is intentional.
|
||||
export async function GET(_request: Request, { params }: RouteParams) {
|
||||
const { token } = await params;
|
||||
|
||||
const result = await resolveActiveShare(token);
|
||||
if (!result.ok) {
|
||||
const { status, body } = rejectionToHttp(result.reason);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
const { share } = result;
|
||||
|
||||
if (!share.coverFileKey) {
|
||||
return NextResponse.json({ error: 'No cover for this share' }, { status: 404 });
|
||||
}
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = await getDownloadSignedUrl(share.coverFileKey, SHARE_PRESIGN_TTL_SECONDS);
|
||||
} catch (err) {
|
||||
console.error('Share cover presign failed:', err);
|
||||
return NextResponse.json({ error: 'Could not sign cover URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(url, {
|
||||
status: 302,
|
||||
headers: { 'Cache-Control': 'public, max-age=300' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { hashShareToken, isValidShareToken } from '@/libs/share-server';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
// POST /api/share/[token]/download/confirm — analytics ping fired by the
|
||||
// landing-page Download button (post-click) and the in-app deeplink hook on
|
||||
// successful import. Best-effort: the user-facing action does not depend on
|
||||
// this returning 2xx. Lookup is by token_hash so the row stays cheap to find.
|
||||
//
|
||||
// Increments are done in a single SQL UPDATE so concurrent requests cannot
|
||||
// race a read-modify-write. We also accept the small risk that an increment
|
||||
// lands shortly after a revoke — that's harmless, the counter doesn't grant
|
||||
// access. The validity check below skips obviously dead shares so crawlers
|
||||
// hitting expired links don't pollute the count after the fact.
|
||||
export async function POST(_request: Request, { params }: RouteParams) {
|
||||
const { token } = await params;
|
||||
|
||||
if (!isValidShareToken(token)) {
|
||||
// Silently OK — this is a best-effort beacon, not an enforcement point.
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const supabase = createSupabaseAdminClient();
|
||||
const tokenHash = await hashShareToken(token);
|
||||
|
||||
// Atomic conditional update via the SQL function defined alongside the
|
||||
// table. Only bumps rows that are still active so late-firing pings on
|
||||
// expired/revoked shares don't pollute the count.
|
||||
const nowIso = new Date().toISOString();
|
||||
const { error } = await supabase.rpc('increment_book_share_download', {
|
||||
p_token_hash: tokenHash,
|
||||
p_now: nowIso,
|
||||
});
|
||||
if (error) {
|
||||
// Best-effort beacon — log but never surface to the caller.
|
||||
console.error('download confirm rpc failed:', error);
|
||||
}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: { 'Cache-Control': 'private, no-store' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDownloadSignedUrl } from '@/utils/object';
|
||||
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
|
||||
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
// GET /api/share/[token]/download — public, 302 to a short-lived presigned URL.
|
||||
// IMPORTANT: this endpoint MUST NOT write to the database. iMessage / WhatsApp /
|
||||
// Slack / Twitter unfurlers and browser prefetchers will hit this URL just by
|
||||
// previewing a link. Counting them would inflate `download_count` to garbage.
|
||||
// Real downloads ping POST /download/confirm separately so the count tracks
|
||||
// user intent, not crawler curiosity.
|
||||
export async function GET(_request: Request, { params }: RouteParams) {
|
||||
const { token } = await params;
|
||||
|
||||
const result = await resolveActiveShare(token);
|
||||
if (!result.ok) {
|
||||
const { status, body } = rejectionToHttp(result.reason);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
const { share } = result;
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = await getDownloadSignedUrl(share.bookFileKey, SHARE_PRESIGN_TTL_SECONDS);
|
||||
} catch (err) {
|
||||
console.error('Share download presign failed:', err);
|
||||
return NextResponse.json({ error: 'Could not sign download URL' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(url, {
|
||||
status: 302,
|
||||
// Don't let intermediaries cache the redirect target itself; the presign
|
||||
// expires fast but caching the 302 would point future requests at a
|
||||
// soon-to-be-dead URL.
|
||||
headers: { 'Cache-Control': 'private, no-store' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { copyObject, objectExists } from '@/utils/object';
|
||||
import {
|
||||
STORAGE_QUOTA_GRACE_BYTES,
|
||||
getStoragePlanData,
|
||||
validateUserAndToken,
|
||||
} from '@/utils/access';
|
||||
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
// POST /api/share/[token]/import — recipient-side library import. Auth required.
|
||||
//
|
||||
// Strategy: R2 server-side byte-copy.
|
||||
// The existing `files` table consumers (stats / purge / delete / download)
|
||||
// all assume `file_key` starts with the row's `user_id`. A reference-based
|
||||
// import would silently break those invariants, so we copy the bytes into
|
||||
// the recipient's namespace instead. R2 server-side copy is one API call
|
||||
// and incurs no egress.
|
||||
//
|
||||
// Idempotent: if the recipient already has a non-deleted `files` row for the
|
||||
// same `book_hash`, we return their existing fileId with `alreadyOwned: true`
|
||||
// and skip the copy. Saves egress on repeated imports.
|
||||
export async function POST(request: Request, { params }: RouteParams) {
|
||||
const { token: shareToken } = await params;
|
||||
|
||||
const { user, token: jwt } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !jwt) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const result = await resolveActiveShare(shareToken);
|
||||
if (!result.ok) {
|
||||
const { status, body } = rejectionToHttp(result.reason);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
const { share } = result;
|
||||
|
||||
// Self-imports are no-ops; redirect the user to their own copy without
|
||||
// burning a copy operation.
|
||||
if (share.userId === user.id) {
|
||||
const supabase = createSupabaseAdminClient();
|
||||
const { data: own } = await supabase
|
||||
.from('files')
|
||||
.select('id, book_hash')
|
||||
.eq('user_id', user.id)
|
||||
.eq('book_hash', share.bookHash)
|
||||
.is('deleted_at', null)
|
||||
.not('file_key', 'ilike', '%.png')
|
||||
.not('file_key', 'ilike', '%.jpg')
|
||||
.not('file_key', 'ilike', '%.jpeg')
|
||||
.not('file_key', 'ilike', '%.webp')
|
||||
.not('file_key', 'ilike', '%.gif')
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (own) {
|
||||
return NextResponse.json({
|
||||
fileId: own.id,
|
||||
alreadyOwned: true,
|
||||
bookHash: share.bookHash,
|
||||
cfi: share.cfi,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
// Idempotency: look up existing rows for the same (user_id, book_hash),
|
||||
// INCLUDING soft-deleted ones. file_key is unique globally, so an active
|
||||
// import that the user later deleted leaves a row that would collide with
|
||||
// a fresh insert below — we restore it instead of failing.
|
||||
const { data: existing, error: existingError } = await supabase
|
||||
.from('files')
|
||||
.select('id, file_key, deleted_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('book_hash', share.bookHash);
|
||||
if (existingError) {
|
||||
console.error('Share import existing-row lookup failed:', existingError);
|
||||
return NextResponse.json({ error: 'Could not check library' }, { status: 500 });
|
||||
}
|
||||
const existingRows = (existing ?? []).filter((f) => !/\.(png|jpe?g|webp|gif)$/i.test(f.file_key));
|
||||
const liveRow = existingRows.find((f) => f.deleted_at === null);
|
||||
if (liveRow) {
|
||||
return NextResponse.json({
|
||||
fileId: liveRow.id,
|
||||
alreadyOwned: true,
|
||||
bookHash: share.bookHash,
|
||||
cfi: share.cfi,
|
||||
});
|
||||
}
|
||||
const deletedRow = existingRows.find((f) => f.deleted_at !== null);
|
||||
if (deletedRow) {
|
||||
// Restore the soft-deleted row so the unique file_key constraint isn't
|
||||
// hit by a fresh insert. The bytes may also still be in storage; if the
|
||||
// copy below succeeds it overwrites them, if it doesn't we leave the
|
||||
// row in its restored state so the user can re-attempt later.
|
||||
const { error: restoreError } = await supabase
|
||||
.from('files')
|
||||
.update({ deleted_at: null, updated_at: new Date().toISOString() })
|
||||
.eq('id', deletedRow.id);
|
||||
if (restoreError) {
|
||||
console.error('Share import restore-deleted-row failed:', restoreError);
|
||||
return NextResponse.json({ error: 'Could not restore book' }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
fileId: deletedRow.id,
|
||||
alreadyOwned: true,
|
||||
bookHash: share.bookHash,
|
||||
cfi: share.cfi,
|
||||
});
|
||||
}
|
||||
|
||||
// Quota check before doing any byte-copy work. JWT-based but consistent
|
||||
// with how the existing upload endpoint enforces it.
|
||||
const { usage, quota } = getStoragePlanData(jwt);
|
||||
if (usage + share.bookSize > quota + STORAGE_QUOTA_GRACE_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Insufficient storage quota', code: 'quota_exceeded', usage, quota },
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
|
||||
// Translate the sharer's file_keys into the recipient's namespace by
|
||||
// swapping the leading user-id prefix. Existing convention: file_key looks
|
||||
// like `${userId}/Readest/Book/{hash}/{filename}`.
|
||||
const sharerPrefix = `${share.userId}/`;
|
||||
const recipientPrefix = `${user.id}/`;
|
||||
|
||||
const remap = (sourceKey: string): string | null => {
|
||||
if (!sourceKey.startsWith(sharerPrefix)) return null;
|
||||
return recipientPrefix + sourceKey.slice(sharerPrefix.length);
|
||||
};
|
||||
|
||||
const destBookKey = remap(share.bookFileKey);
|
||||
if (!destBookKey) {
|
||||
console.error('Share import: source key does not start with sharer user id', share.bookFileKey);
|
||||
return NextResponse.json({ error: 'Cannot remap shared file' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Verify source bytes still exist before allocating a destination row.
|
||||
const sourceExists = await objectExists(share.bookFileKey);
|
||||
if (!sourceExists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Shared book is no longer available', code: 'source_deleted' },
|
||||
{ status: 410 },
|
||||
);
|
||||
}
|
||||
|
||||
// Insert destination row first (to grab a stable id), then copy bytes,
|
||||
// then mark the row clean. On copy failure we soft-delete the row so the
|
||||
// user's library doesn't show a phantom book.
|
||||
const { data: insertedBook, error: insertBookError } = await supabase
|
||||
.from('files')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
book_hash: share.bookHash,
|
||||
file_key: destBookKey,
|
||||
file_size: share.bookSize,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (insertBookError || !insertedBook) {
|
||||
console.error('Share import insert book row failed:', insertBookError);
|
||||
return NextResponse.json({ error: 'Could not import book' }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const copyResp = await copyObject(share.bookFileKey, destBookKey);
|
||||
// R2 (aws4fetch) returns a Response; S3 SDK returns a structured object.
|
||||
// Both throw on hard failures; treat any non-ok HTTP response as a fail.
|
||||
if (copyResp && typeof (copyResp as Response).ok === 'boolean' && !(copyResp as Response).ok) {
|
||||
throw new Error(`R2 copy failed: ${(copyResp as Response).status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Share import book copy failed:', err);
|
||||
// Soft-delete the orphaned row so it doesn't count against quota or appear
|
||||
// in the library list.
|
||||
await supabase
|
||||
.from('files')
|
||||
.update({ deleted_at: new Date().toISOString() })
|
||||
.eq('id', insertedBook.id);
|
||||
return NextResponse.json({ error: 'Could not import book' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Cover is best-effort. A failure here doesn't fail the import — the
|
||||
// recipient still gets the book; the cover will simply be missing in
|
||||
// their library until they refresh from elsewhere.
|
||||
if (share.coverFileKey) {
|
||||
const destCoverKey = remap(share.coverFileKey);
|
||||
if (destCoverKey) {
|
||||
try {
|
||||
const coverExists = await objectExists(share.coverFileKey);
|
||||
if (coverExists) {
|
||||
await copyObject(share.coverFileKey, destCoverKey);
|
||||
await supabase.from('files').insert({
|
||||
user_id: user.id,
|
||||
book_hash: share.bookHash,
|
||||
file_key: destCoverKey,
|
||||
file_size: 0, // unknown; not material — covers don't bill
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Share import cover copy failed (non-fatal):', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
fileId: insertedBook.id,
|
||||
alreadyOwned: false,
|
||||
bookHash: share.bookHash,
|
||||
cfi: share.cfi,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDownloadSignedUrl } from '@/utils/object';
|
||||
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
|
||||
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
|
||||
|
||||
// Edge runtime is required for `next/og` (uses Satori + WASM under the hood
|
||||
// and doesn't run on Node-style serverless). Keeps cold start fast on
|
||||
// CloudFlare Workers via OpenNext.
|
||||
export const runtime = 'edge';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
const WIDTH = 1200;
|
||||
const HEIGHT = 630;
|
||||
|
||||
// GET /api/share/[token]/og.png — server-rendered branded card for chat
|
||||
// unfurls. Stable URL, cached for an hour: unfurlers (iMessage, WhatsApp,
|
||||
// Twitter, Slack) cache aggressively, so a short-lived signed cover URL would
|
||||
// break previews after expiry. By proxying through this route we get a stable
|
||||
// URL even though the underlying R2 object is presigned per-fetch.
|
||||
export async function GET(_request: Request, { params }: RouteParams) {
|
||||
const { token } = await params;
|
||||
|
||||
const result = await resolveActiveShare(token);
|
||||
if (!result.ok) {
|
||||
const { status, body } = rejectionToHttp(result.reason);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
const { share } = result;
|
||||
|
||||
let coverDataUrl: string | null = null;
|
||||
if (share.coverFileKey) {
|
||||
try {
|
||||
const signedUrl = await getDownloadSignedUrl(share.coverFileKey, SHARE_PRESIGN_TTL_SECONDS);
|
||||
const response = await fetch(signedUrl);
|
||||
if (response.ok) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
const contentType = response.headers.get('content-type') ?? 'image/jpeg';
|
||||
coverDataUrl = `data:${contentType};base64,${arrayBufferToBase64(buffer)}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Share og.png cover fetch failed:', err);
|
||||
// Fall through to text-only card.
|
||||
}
|
||||
}
|
||||
|
||||
// JSX form is XSS-safe by construction: ImageResponse escapes text content.
|
||||
// No raw HTML strings cross the boundary.
|
||||
return new ImageResponse(
|
||||
coverDataUrl
|
||||
? withCoverCard(coverDataUrl, share.bookTitle, share.bookAuthor)
|
||||
: textOnlyCard(share.bookTitle, share.bookAuthor),
|
||||
{
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
// Cover-on-left composition. Asymmetric (anti-slop). Cover is the visual
|
||||
// anchor; metadata sits to the right with strong vertical hierarchy.
|
||||
const withCoverCard = (cover: string, title: string, author: string | null) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '64px',
|
||||
gap: '64px',
|
||||
fontFamily: 'serif',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={cover}
|
||||
width={320}
|
||||
height={480}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
border: '1px solid #e5e5e5',
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
alt=''
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
gap: '24px',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
color: '#1a1a1a',
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{clamp(title, 90)}
|
||||
</div>
|
||||
{author && (
|
||||
<div style={{ fontSize: 32, color: '#525252', fontWeight: 400 }}>{clamp(author, 60)}</div>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontSize: 22, color: '#0066cc', fontWeight: 500 }}>Shared via Readest</div>
|
||||
<div style={{ fontSize: 18, color: '#a3a3a3' }}>readest.com</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Cover-less fallback (eng-review locked option A). Title becomes the visual
|
||||
// anchor at display size. No placeholder rectangle, no procedural pattern.
|
||||
const textOnlyCard = (title: string, author: string | null) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '96px 80px',
|
||||
fontFamily: 'serif',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 700,
|
||||
color: '#1a1a1a',
|
||||
lineHeight: 1.05,
|
||||
letterSpacing: '-0.03em',
|
||||
}}
|
||||
>
|
||||
{clamp(title, 80)}
|
||||
</div>
|
||||
{author && (
|
||||
<div style={{ fontSize: 40, color: '#525252', fontWeight: 400 }}>{clamp(author, 60)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontSize: 26, color: '#0066cc', fontWeight: 500 }}>Shared via Readest</div>
|
||||
<div style={{ fontSize: 20, color: '#a3a3a3' }}>readest.com</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const clamp = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { hashShareToken, isValidShareToken } from '@/libs/share-server';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
// POST /api/share/[token]/revoke — owner-only. Sets revoked_at = now() so
|
||||
// future landing-page visits and downloads return 410. Note: presigned URLs
|
||||
// already minted (max ~5 min TTL) cannot be canceled — this is a documented
|
||||
// soft-revocation grace, not a hard guarantee.
|
||||
export async function POST(request: Request, { params }: RouteParams) {
|
||||
const { token } = await params;
|
||||
|
||||
if (!isValidShareToken(token)) {
|
||||
return NextResponse.json({ error: 'Invalid share token' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { user, token: jwt } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !jwt) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createSupabaseAdminClient();
|
||||
const tokenHash = await hashShareToken(token);
|
||||
|
||||
// RLS would suffice, but we use the admin client elsewhere; gate explicitly
|
||||
// on user_id to keep the contract obvious to readers.
|
||||
const { data: share, error: lookupError } = await supabase
|
||||
.from('book_shares')
|
||||
.select('id, user_id, revoked_at')
|
||||
.eq('token_hash', tokenHash)
|
||||
.maybeSingle();
|
||||
|
||||
if (lookupError) {
|
||||
console.error('book_shares lookup failed:', lookupError);
|
||||
return NextResponse.json({ error: 'Could not look up share' }, { status: 500 });
|
||||
}
|
||||
if (!share) {
|
||||
return NextResponse.json({ error: 'Share not found' }, { status: 404 });
|
||||
}
|
||||
if (share.user_id !== user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
// Idempotent: re-revoking returns success without churning the timestamp.
|
||||
if (share.revoked_at) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('book_shares')
|
||||
.update({ revoked_at: new Date().toISOString() })
|
||||
.eq('id', share.id);
|
||||
if (updateError) {
|
||||
console.error('book_shares revoke failed:', updateError);
|
||||
return NextResponse.json({ error: 'Could not revoke share' }, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
// GET /api/share/[token] — public metadata used by the /s landing page.
|
||||
// Returns 410 if the share is revoked, expired, or its source file no longer
|
||||
// exists. Never returns presigned URLs in this body — covers and downloads
|
||||
// are fetched from dedicated endpoints with their own caching semantics.
|
||||
export async function GET(_request: Request, { params }: RouteParams) {
|
||||
const { token } = await params;
|
||||
|
||||
const result = await resolveActiveShare(token);
|
||||
if (!result.ok) {
|
||||
const { status, body } = rejectionToHttp(result.reason);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
const { share } = result;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
title: share.bookTitle,
|
||||
author: share.bookAuthor,
|
||||
format: share.bookFormat,
|
||||
size: share.bookSize,
|
||||
expiresAt: share.expiresAt,
|
||||
hasCover: !!share.coverFileKey,
|
||||
hasCfi: !!share.cfi,
|
||||
downloadCount: share.downloadCount,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'private, no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { generateShareToken } from '@/libs/share-server';
|
||||
import { objectExists } from '@/utils/object';
|
||||
import {
|
||||
SHARE_BASE_URL,
|
||||
SHARE_CFI_MAX_LENGTH,
|
||||
SHARE_EXPIRATION_DAYS,
|
||||
SHARE_MAX_PER_USER,
|
||||
} from '@/services/constants';
|
||||
|
||||
interface CreateShareBody {
|
||||
bookHash?: unknown;
|
||||
expirationDays?: unknown;
|
||||
title?: unknown;
|
||||
author?: unknown;
|
||||
format?: unknown;
|
||||
cfi?: unknown;
|
||||
}
|
||||
|
||||
const isAllowedExpiration = (value: unknown): value is number =>
|
||||
typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
(SHARE_EXPIRATION_DAYS as readonly number[]).includes(value);
|
||||
|
||||
// Bounds the snapshotted text fields the client can pass through. The metadata
|
||||
// is rendered on a public landing page and embedded in the OG image, so a
|
||||
// hostile client could try to abuse very long values for layout disruption.
|
||||
const trimText = (value: unknown, max: number): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
|
||||
};
|
||||
|
||||
// Reject the C0 control range (U+0000-U+001F) and DEL (U+007F). The cfi
|
||||
// is round-tripped into URLs and rendered into HTML; a control byte in
|
||||
// either path would do bad things.
|
||||
const isControlChar = (s: string): boolean => /[\u0000-\u001f\u007f]/.test(s);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: CreateShareBody;
|
||||
try {
|
||||
body = (await request.json()) as CreateShareBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const bookHash = trimText(body.bookHash, 64);
|
||||
if (!bookHash) {
|
||||
return NextResponse.json({ error: 'Missing or invalid bookHash' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isAllowedExpiration(body.expirationDays)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `expirationDays must be one of ${SHARE_EXPIRATION_DAYS.join(', ')}`,
|
||||
code: 'invalid_expiration',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const expirationDays = body.expirationDays;
|
||||
|
||||
const title = trimText(body.title, 512);
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'Missing or invalid title' }, { status: 400 });
|
||||
}
|
||||
const author = trimText(body.author, 256);
|
||||
const format = trimText(body.format, 16);
|
||||
if (!format) {
|
||||
return NextResponse.json({ error: 'Missing or invalid format' }, { status: 400 });
|
||||
}
|
||||
|
||||
let cfi: string | null = null;
|
||||
if (body.cfi != null) {
|
||||
cfi = trimText(body.cfi, SHARE_CFI_MAX_LENGTH);
|
||||
if (cfi && isControlChar(cfi)) {
|
||||
return NextResponse.json({ error: 'cfi contains invalid characters' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
// Active-share cap — silently enforced. Counts only non-revoked, non-expired rows.
|
||||
const { count: activeCount, error: countError } = await supabase
|
||||
.from('book_shares')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.is('revoked_at', null)
|
||||
.gt('expires_at', new Date().toISOString());
|
||||
if (countError) {
|
||||
console.error('book_shares cap query failed:', countError);
|
||||
return NextResponse.json({ error: 'Could not check share quota' }, { status: 500 });
|
||||
}
|
||||
if ((activeCount ?? 0) >= SHARE_MAX_PER_USER) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `You have reached the maximum of ${SHARE_MAX_PER_USER} active shares.`,
|
||||
code: 'share_limit_reached',
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Look up the live `files` row for this user's book. Re-uploads of the same
|
||||
// hash follow the share automatically because we resolve at every access.
|
||||
const { data: bookFiles, error: filesError } = await supabase
|
||||
.from('files')
|
||||
.select('file_key, file_size')
|
||||
.eq('user_id', user.id)
|
||||
.eq('book_hash', bookHash)
|
||||
.is('deleted_at', null);
|
||||
if (filesError) {
|
||||
console.error('book_shares files lookup failed:', filesError);
|
||||
return NextResponse.json({ error: 'Could not look up book' }, { status: 500 });
|
||||
}
|
||||
if (!bookFiles || bookFiles.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Book is not uploaded yet', code: 'book_not_uploaded' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Pick the book file (not the cover) by extension. Covers are PNG/JPG;
|
||||
// book files are EPUB/PDF/MOBI/etc. The widest filter is "is not an image".
|
||||
const bookFile = bookFiles.find((f) => !/\.(png|jpe?g|webp|gif)$/i.test(f.file_key));
|
||||
if (!bookFile) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Book file row not found', code: 'book_not_uploaded' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const size = bookFile.file_size;
|
||||
|
||||
// The `files` row is inserted before bytes upload (storage/upload.ts:74), so
|
||||
// a ghost row can exist if the client aborted. HEAD R2 to confirm bytes are
|
||||
// really there before we make the share publicly resolvable.
|
||||
const exists = await objectExists(bookFile.file_key);
|
||||
if (!exists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Book upload is incomplete; please retry', code: 'upload_incomplete' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const { raw, hash } = await generateShareToken();
|
||||
const expiresAt = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const { error: insertError } = await supabase.from('book_shares').insert({
|
||||
token_hash: hash,
|
||||
token: raw,
|
||||
user_id: user.id,
|
||||
book_hash: bookHash,
|
||||
book_title: title,
|
||||
book_author: author,
|
||||
book_format: format,
|
||||
book_size: size,
|
||||
cfi,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
if (insertError) {
|
||||
console.error('book_shares insert failed:', insertError);
|
||||
return NextResponse.json({ error: 'Could not create share' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
token: raw,
|
||||
url: `${SHARE_BASE_URL}/${raw}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { SHARE_BASE_URL } from '@/services/constants';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
// GET /api/share/list?cursor=<created_at_iso>:<id>
|
||||
// Owner-only. Cursor-paginated list of the caller's shares (active + expired).
|
||||
// Cursor format mirrors the (created_at DESC, id DESC) order so duplicates and
|
||||
// drops are impossible across pages even when rows are added concurrently.
|
||||
export async function GET(request: Request) {
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const rawCursor = url.searchParams.get('cursor');
|
||||
let cursorCreatedAt: string | null = null;
|
||||
let cursorId: string | null = null;
|
||||
if (rawCursor) {
|
||||
const sep = rawCursor.indexOf('|');
|
||||
if (sep > 0) {
|
||||
cursorCreatedAt = rawCursor.slice(0, sep);
|
||||
cursorId = rawCursor.slice(sep + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = createSupabaseAdminClient();
|
||||
let query = supabase
|
||||
.from('book_shares')
|
||||
.select(
|
||||
'id, user_id, token, book_hash, book_title, book_author, book_format, book_size, cfi, expires_at, revoked_at, download_count, created_at',
|
||||
)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.order('id', { ascending: false })
|
||||
.limit(PAGE_SIZE + 1);
|
||||
|
||||
if (cursorCreatedAt && cursorId) {
|
||||
// Strict less-than on (created_at, id) lexicographic to avoid skipping ties.
|
||||
query = query.or(
|
||||
`created_at.lt.${cursorCreatedAt},and(created_at.eq.${cursorCreatedAt},id.lt.${cursorId})`,
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) {
|
||||
console.error('book_shares list failed:', error);
|
||||
return NextResponse.json({ error: 'Could not list shares' }, { status: 500 });
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
const hasMore = rows.length > PAGE_SIZE;
|
||||
const page = hasMore ? rows.slice(0, PAGE_SIZE) : rows;
|
||||
const last = page.length > 0 ? page[page.length - 1] : null;
|
||||
const nextCursor = hasMore && last ? `${last.created_at}|${last.id}` : null;
|
||||
|
||||
return NextResponse.json({
|
||||
shares: page.map((row) => ({
|
||||
id: row.id,
|
||||
// Plaintext token surfaced to the OWNER only. RLS ensures other users
|
||||
// cannot read this row; this endpoint is auth-gated and queried by
|
||||
// user_id so a token never leaves the sharer's session.
|
||||
token: row.token,
|
||||
bookHash: row.book_hash,
|
||||
title: row.book_title,
|
||||
author: row.book_author,
|
||||
format: row.book_format,
|
||||
size: row.book_size,
|
||||
hasCfi: !!row.cfi,
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
downloadCount: row.download_count,
|
||||
createdAt: row.created_at,
|
||||
})),
|
||||
nextCursor,
|
||||
shareUrlBase: SHARE_BASE_URL,
|
||||
});
|
||||
}
|
||||
@@ -48,6 +48,8 @@ import Spinner from '@/components/Spinner';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
|
||||
import SelectModeActions from './SelectModeActions';
|
||||
import ShareBookDialog from './ShareBookDialog';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import GroupingModal from './GroupingModal';
|
||||
import SetStatusAlert from './SetStatusAlert';
|
||||
|
||||
@@ -462,6 +464,31 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { user } = useAuth();
|
||||
const [shareDialogBook, setShareDialogBook] = useState<Book | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShareIntent = (event: CustomEvent) => {
|
||||
const book = (event.detail as { book?: Book } | undefined)?.book;
|
||||
if (!book) return;
|
||||
if (!user) {
|
||||
// Logged-out users can't share their own files; route through the
|
||||
// login flow instead. The /auth route preserves a return path.
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('Sign in to share books'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShareDialogBook(book);
|
||||
};
|
||||
eventDispatcher.on('show-share-dialog', handleShareIntent);
|
||||
return () => {
|
||||
eventDispatcher.off('show-share-dialog', handleShareIntent);
|
||||
};
|
||||
}, [user, _]);
|
||||
|
||||
// OverlayScrollbars + Virtuoso integration: Virtuoso manages its own
|
||||
// scroller; OverlayScrollbars wraps it for overlay scrollbar rendering.
|
||||
const osRootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -698,6 +725,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
onUpdateStatus={updateBooksStatus}
|
||||
/>
|
||||
)}
|
||||
<ShareBookDialog
|
||||
isOpen={!!shareDialogBook}
|
||||
book={shareDialogBook}
|
||||
onClose={() => setShareDialogBook(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -252,6 +252,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
handleBookUpload(book);
|
||||
},
|
||||
});
|
||||
const shareBookMenuItem = await MenuItem.new({
|
||||
text: _('Share Book'),
|
||||
action: async () => {
|
||||
// Bookshelf.tsx hosts the dialog; we dispatch and let it route
|
||||
// unauthenticated users into the login flow first.
|
||||
eventDispatcher.dispatch('show-share-dialog', { book });
|
||||
},
|
||||
});
|
||||
const deleteBookMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
@@ -278,6 +286,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
if (!book.uploadedAt && book.downloadedAt) {
|
||||
menu.append(uploadBookMenuItem);
|
||||
}
|
||||
// Share is offered for any local-or-uploaded book; the dialog will trigger
|
||||
// an upload first if the book hasn't been pushed yet.
|
||||
if (book.downloadedAt || book.uploadedAt) {
|
||||
menu.append(shareBookMenuItem);
|
||||
}
|
||||
menu.append(deleteBookMenuItem);
|
||||
menu.popup();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
IoCheckmarkCircle,
|
||||
IoCopyOutline,
|
||||
IoLinkOutline,
|
||||
IoShareSocialOutline,
|
||||
} from 'react-icons/io5';
|
||||
import Dialog from '@/components/Dialog';
|
||||
import SegmentedControl from '@/components/SegmentedControl';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { Book } from '@/types/book';
|
||||
import { SHARE_DEFAULT_EXPIRATION_DAYS, SHARE_EXPIRATION_DAYS } from '@/services/constants';
|
||||
import { ShareApiError, createShare, revokeShare } from '@/libs/share';
|
||||
import { formatBytes } from '@/utils/book';
|
||||
|
||||
interface ShareBookDialogProps {
|
||||
isOpen: boolean;
|
||||
book: Book | null;
|
||||
// Optional starting position when launched from the reader top bar.
|
||||
// When present, the dialog shows the "Share current page" toggle and
|
||||
// includes the cfi in the create payload if the toggle is checked.
|
||||
cfi?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CreatedShare {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
hasCfi: boolean;
|
||||
}
|
||||
|
||||
const ShareBookDialog: React.FC<ShareBookDialogProps> = ({ isOpen, book, cfi, onClose }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
|
||||
const [expirationDays, setExpirationDays] = useState<number>(SHARE_DEFAULT_EXPIRATION_DAYS);
|
||||
// Off by default — sharing the current page reveals where the user is in
|
||||
// the book, which is mild privacy data. Recipient still gets the book; the
|
||||
// toggle only adds the cfi to the link. Mirrors the dialog's reset effect.
|
||||
const [includeCfi, setIncludeCfi] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<CreatedShare | null>(null);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [fileSize, setFileSize] = useState<number | null>(null);
|
||||
|
||||
// Reset transient state every time the dialog opens for a new book.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setExpirationDays(SHARE_DEFAULT_EXPIRATION_DAYS);
|
||||
setIncludeCfi(false);
|
||||
setGenerating(false);
|
||||
setUploadProgress(null);
|
||||
setErrorMessage(null);
|
||||
setCreated(null);
|
||||
setRevoking(false);
|
||||
setCopied(false);
|
||||
setFileSize(null);
|
||||
}, [isOpen, book?.hash]);
|
||||
|
||||
// Look up the book's file size for the Hero metadata line. Mirrors the
|
||||
// BookDetailModal pattern: ask appService once per (open, book) pair and
|
||||
// tolerate failures silently — file size is decorative, not required.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !book || !appService) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const size = await appService.getBookFileSize(book);
|
||||
if (!cancelled) setFileSize(size);
|
||||
} catch {
|
||||
// Local file may be unavailable (cloud-only book); leave size null.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, book?.hash, appService]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const expiryLabel = useMemo(() => {
|
||||
if (!created) return null;
|
||||
const date = new Date(created.expiresAt);
|
||||
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}, [created]);
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!book || generating) return;
|
||||
setGenerating(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
// Upload first if the book lives only locally.
|
||||
if (!book.uploadedAt && appService) {
|
||||
try {
|
||||
await appService.uploadBook(book, (progress) => {
|
||||
setUploadProgress((progress.progress / progress.total) * 100);
|
||||
});
|
||||
} finally {
|
||||
setUploadProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await createShare({
|
||||
bookHash: book.hash,
|
||||
expirationDays,
|
||||
title: book.title,
|
||||
author: book.author ?? null,
|
||||
format: book.format,
|
||||
cfi: cfi && includeCfi ? cfi : null,
|
||||
});
|
||||
|
||||
setCreated({
|
||||
url: response.url,
|
||||
expiresAt: response.expiresAt,
|
||||
hasCfi: !!(cfi && includeCfi),
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ShareApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: _('Could not create share link');
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!created) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(created.url);
|
||||
setCopied(true);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Link copied'),
|
||||
timeout: 2000,
|
||||
});
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Could not copy link'),
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
if (!created) return;
|
||||
const title = book.title;
|
||||
const url = created.url;
|
||||
|
||||
// Tauri (mobile + desktop windowed): try sharekit. If the import or
|
||||
// call throws, the plugin isn't usable on this platform — fall through
|
||||
// to web/copy. If it resolves (success OR user dismissed the sheet
|
||||
// without picking), we're done; do NOT silently copy on top of that.
|
||||
if (appService?.isMobileApp || appService?.hasWindow) {
|
||||
let sharekitWorked = false;
|
||||
try {
|
||||
const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api');
|
||||
await shareText(`${title}\n${url}`);
|
||||
sharekitWorked = true;
|
||||
} catch (err) {
|
||||
console.error('shareText failed; falling back:', err);
|
||||
}
|
||||
if (sharekitWorked) return;
|
||||
}
|
||||
|
||||
// Web: navigator.share rejects with AbortError when the user dismisses
|
||||
// the share sheet — that's an explicit "don't share" choice, not a
|
||||
// signal to silently copy as a "helpful" fallback. Return on either
|
||||
// resolve or reject; only fall through if the API isn't supported at all.
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
} catch {
|
||||
// User dismissed or share-time error; respect the choice.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Last resort: clipboard copy. Reached only when no native share method
|
||||
// is available on this platform (e.g., Linux desktop without sharekit).
|
||||
await handleCopy();
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!created || revoking) return;
|
||||
if (!created.url) return;
|
||||
// Extract token from the canonical URL we received from the server.
|
||||
const segments = created.url.split('/');
|
||||
const token = segments[segments.length - 1];
|
||||
if (!token) return;
|
||||
setRevoking(true);
|
||||
try {
|
||||
await revokeShare(token);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Share revoked'),
|
||||
timeout: 2000,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : _('Could not revoke share');
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
title={_('Share Book')}
|
||||
onClose={onClose}
|
||||
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
|
||||
contentClassName='!px-6 !py-4'
|
||||
>
|
||||
<div className='flex flex-col gap-5 pt-2'>
|
||||
{/* Hero: cover + metadata. Cover gets a real shadow so it reads as a
|
||||
physical book rather than a bordered thumbnail. Author + format
|
||||
collapse into a single muted line for cleaner hierarchy. */}
|
||||
<div className='flex items-center gap-4'>
|
||||
{book.coverImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={book.coverImageUrl}
|
||||
alt=''
|
||||
className='h-28 w-20 shrink-0 rounded-lg object-cover shadow-md'
|
||||
loading='lazy'
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-base-200 flex h-28 w-20 shrink-0 items-center justify-center rounded-lg shadow-md'>
|
||||
<IoLinkOutline className='text-base-content/30 h-8 w-8' aria-hidden='true' />
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-base-content line-clamp-2 text-lg font-semibold leading-tight'>
|
||||
{book.title}
|
||||
</div>
|
||||
<div className='text-base-content/60 mt-1.5 truncate text-sm'>
|
||||
{[book.author, book.format, formatBytes(fileSize)].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!created ? (
|
||||
<>
|
||||
{/* Settings-card group: each row is a label + control, separated
|
||||
by a hairline divider. Mirrors the iOS native settings idiom. */}
|
||||
<div className='bg-base-200/60 divide-base-content/5 divide-y overflow-hidden rounded-2xl'>
|
||||
{/* Both rows share min-h-12 so the segmented control's internal
|
||||
pill height doesn't make this row visibly taller than the
|
||||
toggle row below. */}
|
||||
<div className='flex min-h-12 items-center justify-between gap-3 px-4 py-2'>
|
||||
<span className='text-base-content text-sm font-medium'>{_('Expires in')}</span>
|
||||
<SegmentedControl<number>
|
||||
ariaLabel={_('Expires in')}
|
||||
value={expirationDays}
|
||||
onChange={setExpirationDays}
|
||||
disabled={generating}
|
||||
options={SHARE_EXPIRATION_DAYS.map((n) => ({
|
||||
value: n,
|
||||
label: _('{{count}} days', { count: n }),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cfi && (
|
||||
<label className='flex min-h-12 cursor-pointer select-none items-center justify-between gap-3 px-4 py-2'>
|
||||
<span className='text-base-content text-sm font-medium'>
|
||||
{_('Share current page')}
|
||||
</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle toggle-primary'
|
||||
checked={includeCfi}
|
||||
onChange={(e) => setIncludeCfi(e.target.checked)}
|
||||
disabled={generating}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadProgress !== null && (
|
||||
<div>
|
||||
<div className='text-base-content/70 mb-1.5 text-xs'>
|
||||
{_('Uploading book…')} {Math.round(uploadProgress)}%
|
||||
</div>
|
||||
<progress
|
||||
className='progress progress-primary w-full'
|
||||
value={uploadProgress}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<p className='text-error text-xs' role='alert'>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className='btn btn-primary btn-block gap-2 rounded-2xl'
|
||||
>
|
||||
<IoLinkOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{generating ? _('Generating…') : _('Generate share link')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{created.hasCfi && (
|
||||
<div className='text-primary inline-flex items-center gap-1.5 self-start text-xs font-medium'>
|
||||
<IoCheckmarkCircle className='h-4 w-4' aria-hidden='true' />
|
||||
{_('Includes your current page')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL pill: softer than a bordered input, with the copy button
|
||||
as an inline action chip. */}
|
||||
<div className='bg-base-200/60 flex items-center gap-2 rounded-2xl p-2 pl-4'>
|
||||
<input
|
||||
type='text'
|
||||
readOnly
|
||||
value={created.url}
|
||||
aria-label={_('Share URL')}
|
||||
className='text-base-content min-w-0 flex-1 bg-transparent font-mono text-xs outline-none'
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleCopy}
|
||||
className={`btn btn-sm gap-1 rounded-xl ${copied ? 'btn-success' : 'btn-primary'}`}
|
||||
aria-label={_('Copy link')}
|
||||
>
|
||||
{copied ? (
|
||||
<IoCheckmarkCircle className='h-4 w-4' aria-hidden='true' />
|
||||
) : (
|
||||
<IoCopyOutline className='h-4 w-4' aria-hidden='true' />
|
||||
)}
|
||||
{copied ? _('Copied') : _('Copy')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleNativeShare}
|
||||
className='btn btn-block gap-2 rounded-2xl'
|
||||
>
|
||||
<IoShareSocialOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{_('Share via…')}
|
||||
</button>
|
||||
|
||||
<p className='text-base-content/60 text-center text-xs'>
|
||||
{_('Expires {{date}}', { date: expiryLabel ?? '' })}
|
||||
<span className='mx-1.5'>·</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRevoke}
|
||||
disabled={revoking}
|
||||
className='link link-error text-xs'
|
||||
>
|
||||
{revoking ? _('Revoking…') : _('Revoke share')}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{errorMessage && (
|
||||
<p className='text-error text-xs' role='alert'>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareBookDialog;
|
||||
@@ -42,6 +42,7 @@ import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useAppUrlIngress } from '@/hooks/useAppUrlIngress';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink';
|
||||
import { useOpenShareLink } from '@/hooks/useOpenShareLink';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
|
||||
import { lockScreenOrientation, selectDirectory } from '@/utils/bridge';
|
||||
@@ -164,6 +165,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
useAppUrlIngress();
|
||||
useOpenWithBooks();
|
||||
useOpenAnnotationLink();
|
||||
useOpenShareLink();
|
||||
useTransferQueue(libraryLoaded);
|
||||
|
||||
const { pullLibrary, pushLibrary } = useBooksSync();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
import { IoAlertCircleOutline, IoBookOutline, IoOpenOutline } from 'react-icons/io5';
|
||||
import { DOWNLOAD_READEST_URL, READEST_WEB_BASE_URL } from '@/services/constants';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { buildAnnotationAppUrl } from '@/utils/deeplink';
|
||||
import { BrandHeader } from '@/components/landing/BrandHeader';
|
||||
import { Card } from '@/components/landing/Card';
|
||||
import { PageFooter } from '@/components/landing/PageFooter';
|
||||
|
||||
type Platform = 'android-chromium' | 'android-other' | 'ios' | 'desktop' | 'unknown';
|
||||
|
||||
@@ -37,39 +39,6 @@ const buildWebReaderUrl = (bookHash: string, cfi: string | null): string => {
|
||||
return `/reader/${bookHash}${query}`;
|
||||
};
|
||||
|
||||
const Card: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className='bg-base-100 border-base-300 mx-4 w-full max-w-md rounded-2xl border p-6 shadow-md sm:p-8'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const BrandHeader: React.FC<{ title: string; subtitle: string; alt: string }> = ({
|
||||
title,
|
||||
subtitle,
|
||||
alt,
|
||||
}) => (
|
||||
<div className='flex flex-col items-center text-center'>
|
||||
<Image src='/icon.png' alt={alt} width={64} height={64} priority className='mb-4 rounded-2xl' />
|
||||
<h1 className='text-base-content text-2xl font-semibold'>{title}</h1>
|
||||
<p className='text-base-content/70 mt-2 text-sm'>{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PageFooter: React.FC<{ tagline: string }> = ({ tagline }) => (
|
||||
<p className='text-base-content/50 mt-6 text-center text-xs'>
|
||||
<a
|
||||
href='https://readest.com'
|
||||
className='hover:text-base-content/80 font-medium transition-colors'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
>
|
||||
Readest
|
||||
</a>
|
||||
<span className='mx-1.5'>·</span>
|
||||
<span>{tagline}</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
const OpenAnnotationLanding = () => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -26,6 +26,7 @@ import WindowButtons from '@/components/WindowButtons';
|
||||
import QuickActionMenu from './annotator/QuickActionMenu';
|
||||
import SidebarToggler from './SidebarToggler';
|
||||
import BookmarkToggler from './BookmarkToggler';
|
||||
import ShareToggler from './ShareToggler';
|
||||
import NotebookToggler from './NotebookToggler';
|
||||
import SettingsToggler from './SettingsToggler';
|
||||
import TranslationToggler from './TranslationToggler';
|
||||
@@ -219,6 +220,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<VscLibrary size={iconSize18} className='fill-base-content' />
|
||||
</button>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
<ShareToggler bookKey={bookKey} />
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
</div>
|
||||
{enableAnnotationQuickActions && (
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
import { clearDiscordPresence } from '@/utils/discord';
|
||||
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
||||
import { BookDetailModal } from '@/components/metadata';
|
||||
import ShareBookDialog from '@/app/library/components/ShareBookDialog';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
import useBooksManager from '../hooks/useBooksManager';
|
||||
import useBookShortcuts from '../hooks/useBookShortcuts';
|
||||
@@ -50,6 +52,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const { initViewState, getViewState, clearViewState } = useReaderStore();
|
||||
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [shareDialogState, setShareDialogState] = useState<{
|
||||
book: Book;
|
||||
cfi: string | null;
|
||||
} | null>(null);
|
||||
const { user } = useAuth();
|
||||
const isInitiating = useRef(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorLoading, setErrorLoading] = useState(false);
|
||||
@@ -104,6 +111,29 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShareIntent = (event: CustomEvent) => {
|
||||
const detail = event.detail as { book: Book; cfi?: string | null } | undefined;
|
||||
if (!detail?.book) return;
|
||||
if (!user) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('Sign in to share books'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShareDialogState({
|
||||
book: detail.book,
|
||||
cfi: detail.cfi ?? null,
|
||||
});
|
||||
};
|
||||
eventDispatcher.on('show-share-dialog', handleShareIntent);
|
||||
return () => {
|
||||
eventDispatcher.off('show-share-dialog', handleShareIntent);
|
||||
};
|
||||
}, [user, _]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookKeys && bookKeys.length > 0) {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
@@ -249,6 +279,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
onClose={() => setShowDetailsBook(null)}
|
||||
/>
|
||||
)}
|
||||
<ShareBookDialog
|
||||
isOpen={!!shareDialogState}
|
||||
book={shareDialogState?.book ?? null}
|
||||
cfi={shareDialogState?.cfi ?? null}
|
||||
onClose={() => setShareDialogState(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { IoShareOutline } from 'react-icons/io5';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
interface ShareTogglerProps {
|
||||
bookKey: string;
|
||||
}
|
||||
|
||||
// Reader top-bar Share button. Matches BookmarkToggler/TranslationToggler
|
||||
// shape so it slots into the existing header rhythm.
|
||||
const ShareToggler: React.FC<ShareTogglerProps> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const { getProgress } = useReaderStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!bookData?.book) return;
|
||||
const progress = getProgress(bookKey);
|
||||
eventDispatcher.dispatch('show-share-dialog', {
|
||||
book: bookData.book,
|
||||
cfi: progress?.location ?? null,
|
||||
});
|
||||
}, [bookKey, getBookData, getProgress]);
|
||||
|
||||
return (
|
||||
<button
|
||||
title={_('Share Book')}
|
||||
type='button'
|
||||
onClick={handleShare}
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
aria-label={_('Share Book')}
|
||||
>
|
||||
<IoShareOutline size={iconSize18} className='fill-base-content' />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareToggler;
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useAppUrlIngress } from '@/hooks/useAppUrlIngress';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink';
|
||||
import { useOpenShareLink } from '@/hooks/useOpenShareLink';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
|
||||
import { tauriHandleSetAlwaysOnTop } from '@/utils/window';
|
||||
@@ -20,6 +21,7 @@ export default function Page() {
|
||||
useAppUrlIngress();
|
||||
useOpenWithBooks();
|
||||
useOpenAnnotationLink();
|
||||
useOpenShareLink();
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Metadata, ResolvingMetadata } from 'next';
|
||||
import { READEST_WEB_BASE_URL, SHARE_BASE_URL } from '@/services/constants';
|
||||
import { resolveActiveShare } from '@/libs/share-server';
|
||||
|
||||
// Server-rendered metadata for chat unfurls. The /s page itself is a client
|
||||
// component that mirrors /o/page.tsx, but the OG/Twitter tags must be in the
|
||||
// initial HTML so iMessage / WhatsApp / Twitter / Slack crawlers can read
|
||||
// them without executing JS.
|
||||
//
|
||||
// In the Tauri build (output: 'export'), this whole route is dropped because
|
||||
// rewrites and dynamic metadata require a server. Tauri intercepts the
|
||||
// readest://share/{token} deep link before /s ever loads.
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
interface MetadataProps {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ searchParams }: MetadataProps,
|
||||
_parent: ResolvingMetadata,
|
||||
): Promise<Metadata> {
|
||||
const params = (await searchParams) ?? {};
|
||||
const tokenParam = params['token'];
|
||||
const token = Array.isArray(tokenParam) ? tokenParam[0] : tokenParam;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
title: 'Open in Readest',
|
||||
description: 'Open-source ebook reader for everyone, on every device.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await resolveActiveShare(token);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
title: 'Share link unavailable · Readest',
|
||||
description: 'This share link is no longer available.',
|
||||
};
|
||||
}
|
||||
const { share } = result;
|
||||
const shareUrl = `${SHARE_BASE_URL}/${token}`;
|
||||
const ogImage = `${READEST_WEB_BASE_URL}/api/share/${token}/og.png`;
|
||||
|
||||
return {
|
||||
title: `${share.bookTitle} · Shared via Readest`,
|
||||
description: share.bookAuthor
|
||||
? `${share.bookAuthor} · Shared via Readest`
|
||||
: 'Shared via Readest',
|
||||
openGraph: {
|
||||
type: 'book',
|
||||
url: shareUrl,
|
||||
title: share.bookTitle,
|
||||
description: share.bookAuthor
|
||||
? `${share.bookAuthor} · Shared via Readest`
|
||||
: 'Shared via Readest',
|
||||
images: [{ url: ogImage, width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: share.bookTitle,
|
||||
description: share.bookAuthor
|
||||
? `${share.bookAuthor} · Shared via Readest`
|
||||
: 'Shared via Readest',
|
||||
images: [ogImage],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function ShareLandingLayout({ children }: LayoutProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
IoAlertCircleOutline,
|
||||
IoBookOutline,
|
||||
IoCloudDownloadOutline,
|
||||
IoLibraryOutline,
|
||||
IoOpenOutline,
|
||||
} from 'react-icons/io5';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { BrandHeader } from '@/components/landing/BrandHeader';
|
||||
import { Card } from '@/components/landing/Card';
|
||||
import { PageFooter } from '@/components/landing/PageFooter';
|
||||
import { confirmDownload, getShare, importShare, type ShareMetadata } from '@/libs/share';
|
||||
import { formatBytes } from '@/utils/book';
|
||||
|
||||
const formatExpiry = (iso: string, _: TranslationFunc): string => {
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
const days = Math.round(ms / (24 * 60 * 60 * 1000));
|
||||
const hours = Math.round(ms / (60 * 60 * 1000));
|
||||
if (days >= 1) return _('Expires in {{count}} days', { count: days });
|
||||
if (hours > 0) return _('Expires in {{count}} hours', { count: hours });
|
||||
return _('Expiring soon');
|
||||
};
|
||||
|
||||
const ShareLanding = () => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Resolve the token from either the rewritten query (?token=) or the pretty
|
||||
// path (/s/{token}). The next.config.mjs rewrite handles the web build; the
|
||||
// pathname fallback handles Tauri (output: 'export', no rewrites), dev
|
||||
// sessions where the rewrite isn't picked up without a server restart, and
|
||||
// any deploy where the rewrite gets misconfigured. Mirrors src/app/o/page.tsx.
|
||||
let token = searchParams?.get('token') ?? '';
|
||||
if (!token && pathname) {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
if (segments[0] === 's' && segments[1]) {
|
||||
token = segments[1];
|
||||
}
|
||||
}
|
||||
|
||||
const [meta, setMeta] = useState<ShareMetadata | null>(null);
|
||||
const [loadError, setLoadError] = useState<{ status: number; message: string } | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoadError({ status: 400, message: _('Missing share token') });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getShare(token);
|
||||
if (!cancelled) setMeta(data);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const status =
|
||||
err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
|
||||
? err.status
|
||||
: 500;
|
||||
setLoadError({ status, message: err instanceof Error ? err.message : 'Unknown error' });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, _]);
|
||||
|
||||
const downloadHref = `/api/share/${encodeURIComponent(token)}/download`;
|
||||
const appHref = `readest://share/${encodeURIComponent(token)}`;
|
||||
|
||||
const handleDownloadClick = () => {
|
||||
// Best-effort analytics ping. Doesn't block the navigation.
|
||||
if (token) confirmDownload(token);
|
||||
};
|
||||
|
||||
const handleAddToLibrary = async () => {
|
||||
if (!token || importing) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const result = await importShare(token);
|
||||
// The reader resolves `ids` via getBookByHash, so pass the book hash —
|
||||
// not the `files.id` UUID. Same identifier used by /reader/:hash links.
|
||||
const params = new URLSearchParams();
|
||||
params.set('ids', result.bookHash);
|
||||
if (result.cfi) params.set('cfi', result.cfi);
|
||||
router.push(`/reader?${params.toString()}`);
|
||||
} catch (err) {
|
||||
setImporting(false);
|
||||
const message = err instanceof Error ? err.message : _('Could not add to your library');
|
||||
setImportError(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadError) {
|
||||
// Pick a body copy that reflects the actual failure mode. Network /
|
||||
// unknown failures get the generic "try again" message; only confirmed
|
||||
// expired/revoked/not-found responses get the "no longer available" copy.
|
||||
// This makes misconfigurations debuggable without inspecting devtools.
|
||||
const isUnavailable = loadError.status === 410 || loadError.status === 404;
|
||||
const isInvalidToken = loadError.status === 400;
|
||||
const heading = isUnavailable
|
||||
? _('This share link is no longer available')
|
||||
: isInvalidToken
|
||||
? _("This link can't be opened")
|
||||
: _('Could not load shared book');
|
||||
const body = isUnavailable
|
||||
? _('The original link may have expired or been revoked.')
|
||||
: isInvalidToken
|
||||
? _('The share link is missing required information.')
|
||||
: _('Please check your connection and try again.');
|
||||
|
||||
return (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<Card>
|
||||
<div className='flex flex-col items-center text-center'>
|
||||
<div className='bg-base-200 mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
<IoAlertCircleOutline className='text-base-content/60 h-8 w-8' />
|
||||
</div>
|
||||
<h1 className='text-base-content text-2xl font-semibold'>{heading}</h1>
|
||||
<p className='text-base-content/70 mt-2 text-sm'>{body}</p>
|
||||
<a
|
||||
href={DOWNLOAD_READEST_URL}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
className='btn btn-ghost btn-block mt-6'
|
||||
>
|
||||
{_('Get Readest')}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<Card>
|
||||
<BrandHeader title={_('Loading shared book…')} alt={_('Readest logo')} />
|
||||
<div
|
||||
className='mt-6 flex flex-col items-center gap-3 py-4'
|
||||
role='status'
|
||||
aria-live='polite'
|
||||
>
|
||||
<span className='loading loading-dots loading-md text-primary' aria-hidden='true' />
|
||||
</div>
|
||||
</Card>
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const coverSrc = meta.hasCover ? `/api/share/${encodeURIComponent(token)}/cover` : null;
|
||||
const expiryLabel = formatExpiry(meta.expiresAt, _);
|
||||
|
||||
return (
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-6'>
|
||||
{/* Inline card instead of <Card>: this surface needs a wider container
|
||||
on desktop (sm:max-w-2xl) and a horizontal cover+content layout
|
||||
that the shared Card primitive doesn't support. The /o landing
|
||||
stays on the narrow Card; only /s gets the wider treatment. */}
|
||||
<div className='bg-base-100 border-base-300/60 mx-auto w-full max-w-md overflow-hidden rounded-2xl border shadow-xl sm:max-w-2xl'>
|
||||
{/* Branded header — small Readest mark + headline. Stays compact so
|
||||
the card still fits on common viewports without scroll, but
|
||||
gives the page identity at a glance. */}
|
||||
<div className='flex flex-col items-center gap-2 px-5 pb-2 pt-5 sm:px-7 sm:pb-3 sm:pt-7'>
|
||||
<Image
|
||||
src='/icon.png'
|
||||
alt={_('Readest logo')}
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
className='rounded-lg'
|
||||
/>
|
||||
<span className='text-base-content text-base font-semibold'>{_('Shared with you')}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col items-center gap-5 px-5 pb-5 sm:flex-row sm:items-stretch sm:gap-7 sm:px-7 sm:pb-7'>
|
||||
{/* Cover: dominant visual anchor. aspect-[2/3] keeps the box the
|
||||
right shape whether or not the image loaded — stable layout
|
||||
while the cover fetches. */}
|
||||
<div className='aspect-[2/3] w-32 shrink-0 overflow-hidden rounded-lg shadow-lg sm:w-40 sm:self-center'>
|
||||
{coverSrc ? (
|
||||
// Plain <img>: source is a presigned URL that varies per
|
||||
// request, so next/image's loader gives no win.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={coverSrc} alt='' className='h-full w-full object-cover' loading='eager' />
|
||||
) : (
|
||||
<div className='bg-base-200 flex h-full w-full items-center justify-center'>
|
||||
<IoBookOutline className='text-base-content/30 h-10 w-10' aria-hidden='true' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content column: title, author, meta line, then actions.
|
||||
Centered on mobile, left-aligned on desktop where it sits to
|
||||
the right of the cover. */}
|
||||
<div className='flex min-w-0 flex-1 flex-col items-center text-center sm:items-start sm:justify-center sm:text-left'>
|
||||
<h1 className='text-base-content line-clamp-3 text-xl font-semibold leading-tight sm:text-2xl'>
|
||||
{meta.title}
|
||||
</h1>
|
||||
{meta.author && (
|
||||
<p className='text-base-content/70 mt-1 truncate text-sm'>{meta.author}</p>
|
||||
)}
|
||||
<p className='text-base-content/50 mt-2 text-xs'>
|
||||
{meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel}
|
||||
</p>
|
||||
|
||||
<div className='mt-4 flex w-full flex-col gap-2 sm:mt-5'>
|
||||
{user ? (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleAddToLibrary}
|
||||
disabled={importing}
|
||||
className='btn btn-primary btn-block flex-nowrap gap-2 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoLibraryOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{importing ? _('Adding…') : _('Add to my library')}
|
||||
</button>
|
||||
{/* Secondary actions side-by-side. flex-nowrap +
|
||||
whitespace-nowrap override daisyUI's default
|
||||
`flex-wrap: wrap` on .btn so the icon and label stay
|
||||
on one line in narrow viewports. */}
|
||||
<div className='flex gap-2'>
|
||||
<a
|
||||
href={downloadHref}
|
||||
onClick={handleDownloadClick}
|
||||
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoCloudDownloadOutline className='h-4 w-4' aria-hidden='true' />
|
||||
{_('Download')}
|
||||
</a>
|
||||
<a
|
||||
href={appHref}
|
||||
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoOpenOutline className='h-4 w-4' aria-hidden='true' />
|
||||
{_('Open in app')}
|
||||
</a>
|
||||
</div>
|
||||
{importError && (
|
||||
<p className='text-error mt-1 text-center text-xs sm:text-left' role='alert'>
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Anonymous flow: only 2 actions, so they sit side-by-side
|
||||
on one row. The 3-action logged-in flow above keeps its
|
||||
stacked design (primary + 2 secondary). Download stays
|
||||
`btn-primary` to keep the "main thing" hierarchy clear,
|
||||
Open in app is ghost. Labels match the logged-in flow's
|
||||
shorter forms to avoid wrap in the tighter row layout. */}
|
||||
<div className='flex w-full gap-2'>
|
||||
<a
|
||||
href={downloadHref}
|
||||
onClick={handleDownloadClick}
|
||||
className='btn btn-primary flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoCloudDownloadOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{_('Download')}
|
||||
</a>
|
||||
<a
|
||||
href={appHref}
|
||||
className='btn btn-ghost flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
|
||||
>
|
||||
<IoOpenOutline className='h-5 w-5' aria-hidden='true' />
|
||||
{_('Open in app')}
|
||||
</a>
|
||||
</div>
|
||||
<p className='text-base-content/60 mt-1 text-center text-xs sm:text-left'>
|
||||
{_("Don't have Readest?")}{' '}
|
||||
<a
|
||||
href={DOWNLOAD_READEST_URL}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
className='text-primary font-medium hover:underline'
|
||||
>
|
||||
{_('Download Readest')}
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
// useSearchParams must be wrapped in Suspense per Next 16 client-component
|
||||
// contract, mirrors src/app/o/page.tsx.
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ShareLanding />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -55,6 +55,7 @@ interface AccountActionsProps {
|
||||
onRestorePurchase?: () => void;
|
||||
onManageSubscription?: () => void;
|
||||
onManageStorage?: () => void;
|
||||
onManageSharedLinks?: () => void;
|
||||
}
|
||||
|
||||
const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
@@ -67,6 +68,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
onRestorePurchase,
|
||||
onManageSubscription,
|
||||
onManageStorage,
|
||||
onManageSharedLinks,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
@@ -116,6 +118,14 @@ const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
{_('Manage Storage')}
|
||||
</button>
|
||||
)}
|
||||
{onManageSharedLinks && (
|
||||
<button
|
||||
onClick={onManageSharedLinks}
|
||||
className='w-full rounded-lg bg-purple-100 px-6 py-3 font-medium text-purple-600 transition-colors hover:bg-purple-200 md:w-auto'
|
||||
>
|
||||
{_('Manage Shared Links')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onResetPassword}
|
||||
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
IoBookOutline,
|
||||
IoCopyOutline,
|
||||
IoLinkOutline,
|
||||
IoShareSocialOutline,
|
||||
IoTrashOutline,
|
||||
} from 'react-icons/io5';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { listShares, revokeShare } from '@/libs/share';
|
||||
import { formatBytes } from '@/utils/book';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import Menu from '@/components/Menu';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface ShareRow {
|
||||
id: string;
|
||||
token: string;
|
||||
bookHash: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
format: string;
|
||||
size: number;
|
||||
hasCfi: boolean;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
downloadCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type Status = 'active' | 'expiring' | 'expired' | 'revoked';
|
||||
|
||||
const getStatus = (row: ShareRow): Status => {
|
||||
if (row.revokedAt) return 'revoked';
|
||||
const ms = new Date(row.expiresAt).getTime() - Date.now();
|
||||
if (ms <= 0) return 'expired';
|
||||
if (ms < 24 * 60 * 60 * 1000) return 'expiring';
|
||||
return 'active';
|
||||
};
|
||||
|
||||
// Small inline cover thumbnail. Falls back to a book-icon placeholder when the
|
||||
// cover endpoint 404s (no cover uploaded) or 410s (share revoked/expired).
|
||||
const ShareCover: React.FC<{ token: string; alt: string }> = ({ token, alt }) => {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className='border-base-300 bg-base-200 flex h-14 w-10 shrink-0 items-center justify-center rounded border'>
|
||||
<IoBookOutline className='text-base-content/40 h-5 w-5' aria-hidden='true' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/share/${encodeURIComponent(token)}/cover`}
|
||||
alt={alt}
|
||||
onError={() => setFailed(true)}
|
||||
className='border-base-300 bg-base-200 h-14 w-10 shrink-0 rounded border object-cover'
|
||||
loading='lazy'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SharedLinksSection: React.FC = () => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const [rows, setRows] = useState<ShareRow[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [shareUrlBase, setShareUrlBase] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (next: string | null, append: boolean) => {
|
||||
if (append) setLoadingMore(true);
|
||||
else setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await listShares(next);
|
||||
const incoming = response.shares as unknown as ShareRow[];
|
||||
setShareUrlBase(
|
||||
(response as unknown as { shareUrlBase?: string }).shareUrlBase ?? shareUrlBase,
|
||||
);
|
||||
setRows((prev) => (append ? [...prev, ...incoming] : incoming));
|
||||
setCursor(response.nextCursor);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : _('Could not load your shares'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[_, shareUrlBase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage(null, false);
|
||||
// Initial load only; pagination is driven by the Load more button.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const buildUrl = (row: ShareRow): string | null => {
|
||||
if (!row.token || !shareUrlBase) return null;
|
||||
return `${shareUrlBase}/${row.token}`;
|
||||
};
|
||||
|
||||
const handleCopy = async (row: ShareRow) => {
|
||||
const url = buildUrl(row);
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Link copied'),
|
||||
timeout: 2000,
|
||||
});
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Could not copy link'),
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeShare = async (row: ShareRow) => {
|
||||
const url = buildUrl(row);
|
||||
if (!url) return;
|
||||
const title = row.title;
|
||||
|
||||
// See ShareBookDialog.handleNativeShare for the rationale: only fall
|
||||
// through to copy when no native share method is available at all.
|
||||
// User-dismissal of the share sheet must NOT silently copy the link.
|
||||
if (appService?.isMobileApp || appService?.hasWindow) {
|
||||
let sharekitWorked = false;
|
||||
try {
|
||||
const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api');
|
||||
await shareText(`${title}\n${url}`);
|
||||
sharekitWorked = true;
|
||||
} catch (err) {
|
||||
console.error('shareText failed; falling back:', err);
|
||||
}
|
||||
if (sharekitWorked) return;
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
} catch {
|
||||
// User dismissed or share-time error; respect the choice.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await handleCopy(row);
|
||||
};
|
||||
|
||||
const handleRevoke = async (row: ShareRow) => {
|
||||
if (!row.token) return;
|
||||
const previous = rows;
|
||||
// Optimistic remove. On failure, restore.
|
||||
setRows((current) => current.filter((r) => r.id !== row.id));
|
||||
try {
|
||||
await revokeShare(row.token);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Share revoked'),
|
||||
timeout: 2000,
|
||||
});
|
||||
} catch (err) {
|
||||
setRows(previous);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : _('Could not revoke share'),
|
||||
timeout: 2500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderExpiry = (row: ShareRow) => {
|
||||
const status = getStatus(row);
|
||||
if (status === 'revoked') return _('Revoked');
|
||||
const ms = new Date(row.expiresAt).getTime() - Date.now();
|
||||
if (status === 'expired') return _('Expired');
|
||||
if (status === 'expiring') {
|
||||
const hours = Math.max(1, Math.round(ms / (60 * 60 * 1000)));
|
||||
return _('Expires in {{count}} hours', { count: hours });
|
||||
}
|
||||
const days = Math.max(1, Math.round(ms / (24 * 60 * 60 * 1000)));
|
||||
return _('Expires in {{count}} days', { count: days });
|
||||
};
|
||||
|
||||
const badgeClass = (status: Status) => {
|
||||
if (status === 'active') return 'badge badge-info';
|
||||
if (status === 'expiring') return 'badge badge-warning';
|
||||
return 'badge badge-ghost';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
|
||||
<div className='mt-4 flex flex-col gap-2'>
|
||||
{[0, 1, 2].map((k) => (
|
||||
<div key={k} className='bg-base-200 h-16 w-full animate-pulse rounded-lg' />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
|
||||
<p className='text-error mt-2 text-sm'>{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
|
||||
</div>
|
||||
<div className='border-base-300 mt-4 flex flex-col items-center gap-3 rounded-2xl border border-dashed p-8 text-center'>
|
||||
<div className='bg-base-200 flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
<IoLinkOutline className='text-base-content/60 h-8 w-8' aria-hidden='true' />
|
||||
</div>
|
||||
<p className='text-base-content text-base font-semibold'>
|
||||
{_("You haven't shared any books yet")}
|
||||
</p>
|
||||
<p className='text-base-content/70 text-sm'>
|
||||
{_('Open a book and tap Share to send it to a friend.')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const activeCount = rows.filter(
|
||||
(r) => getStatus(r) === 'active' || getStatus(r) === 'expiring',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
|
||||
<span className='text-base-content/60 text-xs'>
|
||||
{_('{{count}} active', { count: activeCount })}
|
||||
</span>
|
||||
</div>
|
||||
<ul className='border-base-300 mt-4 divide-y divide-[var(--fallback-bc,oklch(var(--bc)/0.1))] overflow-hidden rounded-2xl border'>
|
||||
{rows.map((row) => {
|
||||
const status = getStatus(row);
|
||||
const dimmed = status === 'expired' || status === 'revoked';
|
||||
return (
|
||||
<li
|
||||
key={row.id}
|
||||
className={`flex items-center gap-3 p-3 sm:p-4 ${dimmed ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<ShareCover token={row.token} alt={row.title} />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-base-content truncate text-sm font-medium'>{row.title}</div>
|
||||
<div className='text-base-content/60 truncate text-xs'>
|
||||
{row.author ?? '—'} · {row.format.toUpperCase()} · {formatBytes(row.size)}
|
||||
</div>
|
||||
<div className='mt-1 flex items-center gap-2 text-xs'>
|
||||
<span className={badgeClass(status)}>{renderExpiry(row)}</span>
|
||||
{row.downloadCount > 0 && (
|
||||
<span className='text-base-content/60'>
|
||||
{_('{{count}} downloads', { count: row.downloadCount })}
|
||||
</span>
|
||||
)}
|
||||
{row.hasCfi && (
|
||||
<span className='text-base-content/60'>{_('starts at saved page')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!dimmed && (
|
||||
<div className='flex items-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
title={_('Copy link')}
|
||||
aria-label={_('Copy link')}
|
||||
onClick={() => handleCopy(row)}
|
||||
className='btn btn-ghost btn-sm'
|
||||
>
|
||||
<IoCopyOutline className='h-4 w-4' aria-hidden='true' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
title={_('Share via…')}
|
||||
aria-label={_('Share via…')}
|
||||
onClick={() => handleNativeShare(row)}
|
||||
className='btn btn-ghost btn-sm'
|
||||
>
|
||||
<IoShareSocialOutline className='h-4 w-4' aria-hidden='true' />
|
||||
</button>
|
||||
{/*
|
||||
* Use the project's Dropdown component (Headless-UI-style with
|
||||
* an Overlay backdrop) instead of daisyUI's <details>/<summary>
|
||||
* pattern. The bare daisyUI version doesn't position correctly
|
||||
* inside this scrollable settings layout — it gets clipped by
|
||||
* the rounded-2xl border on the surrounding <ul>.
|
||||
*/}
|
||||
<Dropdown
|
||||
label={_('More actions')}
|
||||
className='dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost btn-sm'
|
||||
toggleButton={
|
||||
<PiDotsThreeVerticalBold className='h-4 w-4' aria-hidden='true' />
|
||||
}
|
||||
>
|
||||
<Menu className='dropdown-content bg-base-100 rounded-box z-[1] w-44 border p-1 shadow'>
|
||||
<MenuItem
|
||||
label={_('Revoke share')}
|
||||
Icon={IoTrashOutline}
|
||||
onClick={() => handleRevoke(row)}
|
||||
/>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{cursor && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => loadPage(cursor, true)}
|
||||
disabled={loadingMore}
|
||||
className='btn btn-ghost btn-block mt-3'
|
||||
>
|
||||
{loadingMore ? _('Loading…') : _('Load more')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SharedLinksSection;
|
||||
@@ -40,6 +40,7 @@ import UsageStats from './components/UsageStats';
|
||||
import PlansComparison from './components/PlansComparison';
|
||||
import AccountActions from './components/AccountActions';
|
||||
import StorageManager from './components/StorageManager';
|
||||
import SharedLinksSection from './components/SharedLinksSection';
|
||||
import Checkout from './components/Checkout';
|
||||
|
||||
type CheckoutState = {
|
||||
@@ -58,6 +59,7 @@ const ProfilePage = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showEmbeddedCheckout, setShowEmbeddedCheckout] = useState(false);
|
||||
const [showStorageManager, setShowStorageManager] = useState(false);
|
||||
const [showSharedLinksManager, setShowSharedLinksManager] = useState(false);
|
||||
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
|
||||
clientSecret: '',
|
||||
sessionId: '',
|
||||
@@ -105,6 +107,8 @@ const ProfilePage = () => {
|
||||
} else if (showStorageManager) {
|
||||
setShowStorageManager(false);
|
||||
refresh();
|
||||
} else if (showSharedLinksManager) {
|
||||
setShowSharedLinksManager(false);
|
||||
} else {
|
||||
navigateToLibrary(router);
|
||||
}
|
||||
@@ -230,6 +234,10 @@ const ProfilePage = () => {
|
||||
setShowStorageManager(true);
|
||||
};
|
||||
|
||||
const handleManageSharedLinks = () => {
|
||||
setShowSharedLinksManager(true);
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
@@ -292,13 +300,17 @@ const ProfilePage = () => {
|
||||
planDetails={userPlanDetails}
|
||||
/>
|
||||
|
||||
{!showStorageManager && <UsageStats quotas={quotas} />}
|
||||
{!showStorageManager && !showSharedLinksManager && <UsageStats quotas={quotas} />}
|
||||
</div>
|
||||
|
||||
{showStorageManager ? (
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<StorageManager />
|
||||
</div>
|
||||
) : showSharedLinksManager ? (
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<SharedLinksSection />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex flex-col gap-y-8 sm:px-6'>
|
||||
@@ -323,6 +335,7 @@ const ProfilePage = () => {
|
||||
onRestorePurchase={handleIAPRestorePurchase}
|
||||
onManageSubscription={handleManageSubscription}
|
||||
onManageStorage={handleManageStorage}
|
||||
onManageSharedLinks={handleManageSharedLinks}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
export interface SegmentedControlOption<T extends string | number> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
// Optional accessible label when `label` is a non-text node (icon, badge…).
|
||||
ariaLabel?: string;
|
||||
// Per-option disable, in addition to the group-level `disabled` prop.
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string | number> {
|
||||
options: ReadonlyArray<SegmentedControlOption<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
// Group-level accessible name (rendered as `aria-label` on the wrapper).
|
||||
ariaLabel?: string;
|
||||
// Group-level disable. Per-option `disabled` is OR'd with this.
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
// Stretch segments to fill the container; otherwise they hug their content.
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// iOS-style segmented control: a subtle track holds N equally-weighted
|
||||
// segments. The active one rises on top as a filled pill with a slight
|
||||
// shadow; inactive ones are flat, transparent, and slightly muted so the
|
||||
// group reads as a single control rather than a row of separate buttons.
|
||||
//
|
||||
// Generic over the value type so callers preserve number / string / enum
|
||||
// semantics:
|
||||
//
|
||||
// <SegmentedControl<number>
|
||||
// options={[{ value: 1, label: '1 day' }, ...]}
|
||||
// value={days}
|
||||
// onChange={setDays}
|
||||
// />
|
||||
const SegmentedControl = <T extends string | number>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
size = 'sm',
|
||||
fullWidth = false,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) => {
|
||||
const sizeClasses = size === 'md' ? 'px-4 py-1.5 text-sm' : 'px-3 py-1 text-sm';
|
||||
|
||||
return (
|
||||
<div
|
||||
role='radiogroup'
|
||||
aria-label={ariaLabel}
|
||||
className={clsx(
|
||||
'bg-base-300/60 rounded-lg p-0.5',
|
||||
fullWidth ? 'flex w-full' : 'inline-flex',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
const optionDisabled = !!disabled || !!option.disabled;
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type='button'
|
||||
role='radio'
|
||||
aria-checked={selected}
|
||||
aria-label={option.ariaLabel}
|
||||
disabled={optionDisabled}
|
||||
onClick={() => {
|
||||
if (!selected) onChange(option.value);
|
||||
}}
|
||||
className={clsx(
|
||||
'rounded-md font-medium transition-colors disabled:opacity-50',
|
||||
fullWidth && 'flex-1',
|
||||
sizeClasses,
|
||||
selected
|
||||
? 'bg-primary text-primary-content shadow-sm'
|
||||
: 'text-base-content/70 hover:text-base-content',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SegmentedControl;
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface BrandHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export const BrandHeader: React.FC<BrandHeaderProps> = ({ title, subtitle, alt }) => (
|
||||
<div className='flex flex-col items-center text-center'>
|
||||
<Image src='/icon.png' alt={alt} width={64} height={64} priority className='mb-4 rounded-2xl' />
|
||||
<h1 className='text-base-content text-2xl font-semibold'>{title}</h1>
|
||||
{subtitle && <p className='text-base-content/70 mt-2 text-sm'>{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// Card primitive shared by /o (annotation deeplink) and /s (share link)
|
||||
// landing pages. Mirrors the visual reference in src/app/o/page.tsx so the
|
||||
// two surfaces feel like the same Readest product.
|
||||
export const Card: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className='bg-base-100 border-base-300 mx-4 w-full max-w-md rounded-2xl border p-6 shadow-md sm:p-8'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface PageFooterProps {
|
||||
tagline: string;
|
||||
}
|
||||
|
||||
export const PageFooter: React.FC<PageFooterProps> = ({ tagline }) => (
|
||||
<p className='text-base-content/50 mt-6 text-center text-xs'>
|
||||
<a
|
||||
href='https://readest.com'
|
||||
className='hover:text-base-content/80 font-medium transition-colors'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
>
|
||||
Readest
|
||||
</a>
|
||||
<span className='mx-1.5'>·</span>
|
||||
<span>{tagline}</span>
|
||||
</p>
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getCurrent } from '@tauri-apps/plugin-deep-link';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { ShareApiError, confirmDownload, importShare } from '@/libs/share';
|
||||
import { parseShareDeepLink, type ShareDeepLink } from '@/utils/share';
|
||||
import { useTranslation } from './useTranslation';
|
||||
|
||||
// Module-scoped flag matches the useOpenAnnotationLink pattern. Tauri's
|
||||
// getCurrent() keeps returning the launch URL for the entire app session, so
|
||||
// without this every remount would re-process the cold-start URL.
|
||||
let coldStartConsumed = false;
|
||||
|
||||
/**
|
||||
* Receive book share deep links and import the book into the user's library.
|
||||
*
|
||||
* Architecture:
|
||||
* - useOpenWithBooks owns the Tauri URL channels and re-broadcasts every
|
||||
* URL as the 'app-incoming-url' event. This hook subscribes for warm /
|
||||
* live deliveries.
|
||||
* - For cold-start, getCurrent() is read once at module scope.
|
||||
* - Library-load deferral: on cold-start the URL may arrive before the
|
||||
* library store hydrates. Stash and replay once libraryLoaded.
|
||||
*
|
||||
* Supported URL shapes (see src/utils/share.ts):
|
||||
* readest://share/{token}
|
||||
* https://web.readest.com/s/{token}
|
||||
*
|
||||
* Auth-gated paths:
|
||||
* - Logged-in: POST /api/share/[token]/import (server-side R2 byte-copy),
|
||||
* navigate to the new fileId in the reader at the sharer's cfi.
|
||||
* - Logged-out: surface a toast directing the user to the web landing
|
||||
* page where they can download anonymously. We don't try to do an
|
||||
* anonymous download here — the in-app library is the user's space and
|
||||
* a logged-out import has nowhere to land.
|
||||
*/
|
||||
export function useOpenShareLink() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const libraryLoaded = useLibraryStore((s) => s.libraryLoaded);
|
||||
const pending = useRef<ShareDeepLink | null>(null);
|
||||
|
||||
const handleShareLink = useCallback(
|
||||
async ({ token }: ShareDeepLink) => {
|
||||
if (!user) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('Sign in to import shared books'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await importShare(token);
|
||||
// Best-effort analytics ping; doesn't affect UX.
|
||||
confirmDownload(token);
|
||||
|
||||
// The reader resolves IDs via getBookByHash, so navigate with the
|
||||
// book hash (not the `files.id` UUID).
|
||||
const queryParams = result.cfi ? `cfi=${encodeURIComponent(result.cfi)}` : undefined;
|
||||
navigateToReader(router, [result.bookHash], queryParams);
|
||||
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: result.alreadyOwned ? _('Already in your library') : _('Added to your library'),
|
||||
timeout: 2500,
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ShareApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: _('Could not import shared book');
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message,
|
||||
timeout: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
[_, router, user],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform() || !appService) return;
|
||||
|
||||
const handle = (url: string) => {
|
||||
const parsed = parseShareDeepLink(url);
|
||||
if (!parsed) return;
|
||||
if (!useLibraryStore.getState().libraryLoaded) {
|
||||
pending.current = parsed;
|
||||
return;
|
||||
}
|
||||
void handleShareLink(parsed);
|
||||
};
|
||||
|
||||
if (!coldStartConsumed) {
|
||||
coldStartConsumed = true;
|
||||
getCurrent()
|
||||
.then((urls) => urls?.forEach(handle))
|
||||
.catch(() => {
|
||||
// Plugin not available on this platform — live channel still works.
|
||||
});
|
||||
}
|
||||
|
||||
const onIncoming = (event: CustomEvent) => {
|
||||
const { urls } = event.detail as { urls: string[] };
|
||||
urls.forEach(handle);
|
||||
};
|
||||
eventDispatcher.on('app-incoming-url', onIncoming);
|
||||
|
||||
return () => {
|
||||
eventDispatcher.off('app-incoming-url', onIncoming);
|
||||
};
|
||||
}, [appService, handleShareLink]);
|
||||
|
||||
// Replay any deferred deep link once the library hydrates.
|
||||
useEffect(() => {
|
||||
if (!libraryLoaded || !pending.current) return;
|
||||
const parsed = pending.current;
|
||||
pending.current = null;
|
||||
void handleShareLink(parsed);
|
||||
}, [libraryLoaded, handleShareLink]);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
|
||||
// 22-char URL-safe alphabet (alphanumeric only — no `-` or `_`). Avoids
|
||||
// punctuation that some chat clients linkify oddly.
|
||||
const SHARE_TOKEN_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const SHARE_TOKEN_LENGTH = 22;
|
||||
const generator = customAlphabet(SHARE_TOKEN_ALPHABET, SHARE_TOKEN_LENGTH);
|
||||
|
||||
const SHARE_TOKEN_REGEX = new RegExp(`^[${SHARE_TOKEN_ALPHABET}]{${SHARE_TOKEN_LENGTH}}$`);
|
||||
|
||||
export const isValidShareToken = (token: unknown): token is string =>
|
||||
typeof token === 'string' && SHARE_TOKEN_REGEX.test(token);
|
||||
|
||||
// Generate a fresh share token. The raw value is shown to the user once at
|
||||
// create-time; only the hash is persisted to the database. A leaked DB read
|
||||
// therefore cannot recover live bearer credentials.
|
||||
export const generateShareToken = async (): Promise<{ raw: string; hash: string }> => {
|
||||
const raw = generator();
|
||||
const hash = await hashShareToken(raw);
|
||||
return { raw, hash };
|
||||
};
|
||||
|
||||
// SHA-256 of the raw token. Used at create (insert) and lookup (constant-time
|
||||
// comparison via the unique index). Implemented with WebCrypto so it runs in
|
||||
// both Node and edge runtimes.
|
||||
export const hashShareToken = async (raw: string): Promise<string> => {
|
||||
const data = new TextEncoder().encode(raw);
|
||||
const buffer = await crypto.subtle.digest('SHA-256', data);
|
||||
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
// Reasons a share lookup may reject.
|
||||
export type ShareLookupRejection =
|
||||
| { kind: 'invalid_token' }
|
||||
| { kind: 'not_found' }
|
||||
| { kind: 'revoked' }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'source_deleted' }
|
||||
| { kind: 'lookup_failed'; detail?: string };
|
||||
|
||||
export interface ResolvedShare {
|
||||
id: string;
|
||||
userId: string;
|
||||
bookHash: string;
|
||||
bookTitle: string;
|
||||
bookAuthor: string | null;
|
||||
bookFormat: string;
|
||||
bookSize: number;
|
||||
cfi: string | null;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
downloadCount: number;
|
||||
createdAt: string;
|
||||
bookFileKey: string;
|
||||
coverFileKey: string | null;
|
||||
}
|
||||
|
||||
const isCoverKey = (fileKey: string): boolean => /\.(png|jpe?g|webp|gif)$/i.test(fileKey);
|
||||
|
||||
// Single source of truth for the "is this share alive and usable?" check.
|
||||
// Used by the public metadata, download, cover, og.png, and import routes
|
||||
// so the validation logic stays in one place.
|
||||
export const resolveActiveShare = async (
|
||||
rawToken: string,
|
||||
): Promise<{ ok: true; share: ResolvedShare } | { ok: false; reason: ShareLookupRejection }> => {
|
||||
if (!isValidShareToken(rawToken)) {
|
||||
return { ok: false, reason: { kind: 'invalid_token' } };
|
||||
}
|
||||
|
||||
const supabase = createSupabaseAdminClient();
|
||||
const tokenHash = await hashShareToken(rawToken);
|
||||
|
||||
const { data: row, error } = await supabase
|
||||
.from('book_shares')
|
||||
.select(
|
||||
'id, user_id, book_hash, book_title, book_author, book_format, book_size, cfi, expires_at, revoked_at, download_count, created_at',
|
||||
)
|
||||
.eq('token_hash', tokenHash)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return { ok: false, reason: { kind: 'lookup_failed', detail: error.message } };
|
||||
}
|
||||
if (!row) {
|
||||
return { ok: false, reason: { kind: 'not_found' } };
|
||||
}
|
||||
if (row.revoked_at) {
|
||||
return { ok: false, reason: { kind: 'revoked' } };
|
||||
}
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||
return { ok: false, reason: { kind: 'expired' } };
|
||||
}
|
||||
|
||||
const { data: files, error: filesError } = await supabase
|
||||
.from('files')
|
||||
.select('file_key')
|
||||
.eq('user_id', row.user_id)
|
||||
.eq('book_hash', row.book_hash)
|
||||
.is('deleted_at', null);
|
||||
if (filesError) {
|
||||
return { ok: false, reason: { kind: 'lookup_failed', detail: filesError.message } };
|
||||
}
|
||||
|
||||
const bookFile = files?.find((f) => !isCoverKey(f.file_key));
|
||||
if (!bookFile) {
|
||||
return { ok: false, reason: { kind: 'source_deleted' } };
|
||||
}
|
||||
const coverFile = files?.find((f) => isCoverKey(f.file_key));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
share: {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
bookHash: row.book_hash,
|
||||
bookTitle: row.book_title,
|
||||
bookAuthor: row.book_author,
|
||||
bookFormat: row.book_format,
|
||||
bookSize: row.book_size,
|
||||
cfi: row.cfi,
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
downloadCount: row.download_count,
|
||||
createdAt: row.created_at,
|
||||
bookFileKey: bookFile.file_key,
|
||||
coverFileKey: coverFile?.file_key ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Maps the rejection kinds to the standard HTTP status + code combinations
|
||||
// used by every share endpoint. Centralized so the JSON error shape is
|
||||
// consistent across routes.
|
||||
export const rejectionToHttp = (
|
||||
reason: ShareLookupRejection,
|
||||
): { status: number; body: { error: string; code?: string } } => {
|
||||
switch (reason.kind) {
|
||||
case 'invalid_token':
|
||||
return { status: 400, body: { error: 'Invalid share token', code: 'invalid_token' } };
|
||||
case 'not_found':
|
||||
return { status: 404, body: { error: 'Share not found', code: 'not_found' } };
|
||||
case 'revoked':
|
||||
return { status: 410, body: { error: 'Share has been revoked', code: 'revoked' } };
|
||||
case 'expired':
|
||||
return { status: 410, body: { error: 'Share has expired', code: 'expired' } };
|
||||
case 'source_deleted':
|
||||
return {
|
||||
status: 410,
|
||||
body: { error: 'Shared book is no longer available', code: 'source_deleted' },
|
||||
};
|
||||
case 'lookup_failed':
|
||||
console.error('Share lookup failed:', reason.detail);
|
||||
return { status: 500, body: { error: 'Could not look up share' } };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { fetchWithAuth } from '@/utils/fetch';
|
||||
|
||||
const SHARE_API = getAPIBaseUrl() + '/share';
|
||||
|
||||
export interface CreateShareInput {
|
||||
bookHash: string;
|
||||
expirationDays: number; // must be one of [1, 3, 7]
|
||||
title: string;
|
||||
author?: string | null;
|
||||
format: string;
|
||||
// Note: `size` is intentionally not part of the input. The server reads the
|
||||
// canonical size from the user's `files` row to avoid client/server drift.
|
||||
cfi?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateShareResponse {
|
||||
token: string;
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ShareMetadata {
|
||||
title: string;
|
||||
author: string | null;
|
||||
format: string;
|
||||
size: number;
|
||||
expiresAt: string;
|
||||
hasCover: boolean;
|
||||
hasCfi: boolean;
|
||||
downloadCount: number;
|
||||
// Owner-only fields (returned only when the caller is the sharer).
|
||||
token?: string;
|
||||
bookHash?: string;
|
||||
createdAt?: string;
|
||||
revokedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ShareListResponse {
|
||||
shares: Array<
|
||||
ShareMetadata & {
|
||||
token: string;
|
||||
bookHash: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
>;
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface ImportShareResponse {
|
||||
fileId: string;
|
||||
alreadyOwned: boolean;
|
||||
bookHash: string;
|
||||
cfi: string | null;
|
||||
}
|
||||
|
||||
export class ShareApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string | undefined,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ShareApiError';
|
||||
}
|
||||
}
|
||||
|
||||
const parseError = async (response: Response): Promise<ShareApiError> => {
|
||||
let code: string | undefined;
|
||||
let message = response.statusText || 'Request failed';
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string; code?: string };
|
||||
if (body?.error) message = body.error;
|
||||
if (body?.code) code = body.code;
|
||||
} catch {
|
||||
// Body wasn't JSON; keep the default message.
|
||||
}
|
||||
return new ShareApiError(response.status, code, message);
|
||||
};
|
||||
|
||||
const jsonHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
// Owner-only. Creates a share row for an already-uploaded book.
|
||||
export const createShare = async (input: CreateShareInput): Promise<CreateShareResponse> => {
|
||||
const response = await fetchWithAuth(`${SHARE_API}/create`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) throw await parseError(response);
|
||||
return (await response.json()) as CreateShareResponse;
|
||||
};
|
||||
|
||||
// Public. Used by the landing page to render metadata.
|
||||
export const getShare = async (token: string): Promise<ShareMetadata> => {
|
||||
const response = await fetch(`${SHARE_API}/${encodeURIComponent(token)}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) throw await parseError(response);
|
||||
return (await response.json()) as ShareMetadata;
|
||||
};
|
||||
|
||||
// Owner-only. Revokes a share immediately. Note that already-minted presigned
|
||||
// download URLs remain valid until their TTL expires (max ~5 min).
|
||||
export const revokeShare = async (token: string): Promise<void> => {
|
||||
const response = await fetchWithAuth(`${SHARE_API}/${encodeURIComponent(token)}/revoke`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw await parseError(response);
|
||||
};
|
||||
|
||||
// Owner-only. Paginated list of the caller's shares (active + expired).
|
||||
export const listShares = async (cursor?: string | null): Promise<ShareListResponse> => {
|
||||
// SHARE_API is relative in dev (`/api/share`) and absolute in prod, so we
|
||||
// can't use `new URL()` here unconditionally — relative paths throw
|
||||
// "Invalid URL" without a base. Build the query string manually.
|
||||
const qs = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
|
||||
const response = await fetchWithAuth(`${SHARE_API}/list${qs}`, { method: 'GET' });
|
||||
if (!response.ok) throw await parseError(response);
|
||||
return (await response.json()) as ShareListResponse;
|
||||
};
|
||||
|
||||
// Recipient-side, requires auth. Adds the shared book to the caller's library
|
||||
// by R2 server-side byte-copy. Idempotent: if the recipient already owns a
|
||||
// non-deleted file with the same book_hash, returns alreadyOwned: true and
|
||||
// the existing fileId.
|
||||
export const importShare = async (token: string): Promise<ImportShareResponse> => {
|
||||
const response = await fetchWithAuth(`${SHARE_API}/${encodeURIComponent(token)}/import`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw await parseError(response);
|
||||
return (await response.json()) as ImportShareResponse;
|
||||
};
|
||||
|
||||
// Public. Best-effort analytics ping fired by the landing page Download button
|
||||
// and the in-app deeplink hook on a successful import. Failures are silent —
|
||||
// the user-visible action does NOT depend on this succeeding.
|
||||
export const confirmDownload = async (token: string): Promise<void> => {
|
||||
try {
|
||||
await fetch(`${SHARE_API}/${encodeURIComponent(token)}/download/confirm`, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Intentionally swallowed; this is analytics, not a gate.
|
||||
}
|
||||
};
|
||||
@@ -728,6 +728,14 @@ export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web'
|
||||
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
|
||||
export const READEST_NODE_BASE_URL = 'https://node.readest.com';
|
||||
|
||||
export const SHARE_BASE_URL = `${READEST_WEB_BASE_URL}/s`;
|
||||
export const SHARE_EXPIRATION_DAYS = [1, 3, 7] as const;
|
||||
export const SHARE_DEFAULT_EXPIRATION_DAYS = 3;
|
||||
export const SHARE_MAX_PER_USER = 50;
|
||||
export const SHARE_TOKEN_LENGTH = 22;
|
||||
export const SHARE_PRESIGN_TTL_SECONDS = 300;
|
||||
export const SHARE_CFI_MAX_LENGTH = 512;
|
||||
|
||||
const LATEST_DOWNLOAD_BASE_URL = 'https://download.readest.com/releases';
|
||||
|
||||
export const READEST_UPDATER_FILE = `${LATEST_DOWNLOAD_BASE_URL}/latest.json`;
|
||||
|
||||
@@ -43,3 +43,39 @@ export const deleteObject = async (fileKey: string, bucketName?: string) => {
|
||||
return await s3Storage.deleteObject(bucketName, fileKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns true if the object exists in storage. Used to verify uploads completed
|
||||
// before treating a `files` row as shareable.
|
||||
export const objectExists = async (fileKey: string, bucketName?: string): Promise<boolean> => {
|
||||
const storageType = getStorageType();
|
||||
try {
|
||||
if (storageType === 'r2') {
|
||||
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
|
||||
const response = await r2Storage.headObject(bucketName, fileKey);
|
||||
return response.ok;
|
||||
} else {
|
||||
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
|
||||
await s3Storage.headObject(bucketName, fileKey);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Server-side byte copy used by /api/share/[token]/import to clone a shared
|
||||
// book into the recipient's namespace without egress.
|
||||
export const copyObject = async (
|
||||
sourceFileKey: string,
|
||||
destFileKey: string,
|
||||
bucketName?: string,
|
||||
) => {
|
||||
const storageType = getStorageType();
|
||||
if (storageType === 'r2') {
|
||||
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
|
||||
return await r2Storage.copyObject(bucketName, sourceFileKey, destFileKey);
|
||||
} else {
|
||||
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
|
||||
return await s3Storage.copyObject(bucketName, sourceFileKey, destFileKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,4 +59,38 @@ export const r2Storage = {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
headObject: async (bucketName: string, fileKey: string) => {
|
||||
const response = await r2Storage
|
||||
.getR2Client()
|
||||
.fetch(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}`, {
|
||||
method: 'HEAD',
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
copyObject: async (
|
||||
bucketName: string,
|
||||
sourceFileKey: string,
|
||||
destFileKey: string,
|
||||
sourceBucketName?: string,
|
||||
) => {
|
||||
const srcBucket = sourceBucketName || bucketName;
|
||||
// S3 / R2 require the copy-source header to be URL-encoded segment-by-
|
||||
// segment. file_key is built from the original filename, so spaces and
|
||||
// reserved chars (e.g. `My Book.epub`, `A&B.epub`) are common and would
|
||||
// otherwise break the copy. We encode each path segment but keep the
|
||||
// separating slashes literal.
|
||||
const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
|
||||
const copySource = `/${srcBucket}/${encodeKey(sourceFileKey)}`;
|
||||
const response = await r2Storage
|
||||
.getR2Client()
|
||||
.fetch(`${r2Storage.getR2Url()}/${bucketName}/${destFileKey}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-amz-copy-source': copySource,
|
||||
},
|
||||
});
|
||||
return response;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
import { GetObjectCommand, DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
PutObjectCommand,
|
||||
HeadObjectCommand,
|
||||
CopyObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
const S3_ENDPOINT = process.env['S3_ENDPOINT'] || '';
|
||||
@@ -71,4 +77,34 @@ export const s3Storage = {
|
||||
|
||||
return await s3Storage.getClient().send(deleteCommand);
|
||||
},
|
||||
|
||||
headObject: async (bucketName: string, fileKey: string) => {
|
||||
const headCommand = new HeadObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: fileKey,
|
||||
});
|
||||
|
||||
return await s3Storage.getClient().send(headCommand);
|
||||
},
|
||||
|
||||
copyObject: async (
|
||||
bucketName: string,
|
||||
sourceFileKey: string,
|
||||
destFileKey: string,
|
||||
sourceBucketName?: string,
|
||||
) => {
|
||||
const srcBucket = sourceBucketName || bucketName;
|
||||
// S3 requires CopySource to be URL-encoded segment-by-segment. file_key
|
||||
// is built from the original filename, so spaces and reserved chars
|
||||
// (e.g. `My Book.epub`, `A&B.epub`) are common and would otherwise
|
||||
// break the copy.
|
||||
const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
|
||||
const copyCommand = new CopyObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: destFileKey,
|
||||
CopySource: `${srcBucket}/${encodeKey(sourceFileKey)}`,
|
||||
});
|
||||
|
||||
return await s3Storage.getClient().send(copyCommand);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { READEST_WEB_BASE_URL, SHARE_BASE_URL, SHARE_TOKEN_LENGTH } from '@/services/constants';
|
||||
|
||||
export interface ShareDeepLink {
|
||||
token: string;
|
||||
// Reserved for future query params (e.g., recipient locale, share variant).
|
||||
// Currently no params are emitted, but parseShareDeepLink preserves the
|
||||
// shape so callers don't need to be updated when more arrive.
|
||||
}
|
||||
|
||||
const TOKEN_RE = new RegExp(`^[A-Za-z0-9]{${SHARE_TOKEN_LENGTH}}$`);
|
||||
|
||||
const isValidToken = (raw: unknown): raw is string => typeof raw === 'string' && TOKEN_RE.test(raw);
|
||||
|
||||
// Canonical share URL embedded in the dialog, share sheet, and any "copy link"
|
||||
// affordance. Always points at the public web target.
|
||||
export const buildShareUrl = (token: string): string => `${SHARE_BASE_URL}/${token}`;
|
||||
|
||||
// Parses both the custom-scheme and HTTPS forms used by the deeplink ingress.
|
||||
// readest://share/{token}
|
||||
// https://web.readest.com/s/{token}
|
||||
// Returns null on invalid input so callers can fall through to other parsers.
|
||||
export const parseShareDeepLink = (url: string): ShareDeepLink | null => {
|
||||
if (!url) return null;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol === 'readest:') {
|
||||
// For readest://share/{token} the host portion holds the path segment
|
||||
// before the slash. Use pathname for the token; url.host == 'share'.
|
||||
if (parsed.host !== 'share') return null;
|
||||
const token = parsed.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
return isValidToken(token) ? { token } : null;
|
||||
}
|
||||
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
|
||||
if (!isWebReadestHost(parsed.host)) return null;
|
||||
const segments = parsed.pathname.split('/').filter(Boolean);
|
||||
if (segments.length !== 2 || segments[0] !== 's') return null;
|
||||
const token = segments[1]!;
|
||||
return isValidToken(token) ? { token } : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isWebReadestHost = (host: string): boolean => {
|
||||
// Matches the production host and any preview domain Readest may serve from.
|
||||
// Conservative: accepts only the exact production host or a *.readest.com
|
||||
// subdomain so a third-party site cannot impersonate a share URL.
|
||||
if (host === new URL(READEST_WEB_BASE_URL).host) return true;
|
||||
return host.endsWith('.readest.com');
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Migration 002: Add book_shares table for time-limited share links
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.book_shares (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
-- token_hash is the lookup key (sha256(token)). It is unique-indexed so
|
||||
-- public landing-page reads and downloads are O(1). The plaintext token
|
||||
-- is also stored so the owner can copy links after the create dialog
|
||||
-- closes. RLS prevents anyone but the owner from reading the plaintext.
|
||||
-- Public endpoints look up by hash only and never select the token column.
|
||||
token_hash text NOT NULL,
|
||||
token text NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
book_hash text NOT NULL,
|
||||
book_title text NOT NULL,
|
||||
book_author text NULL,
|
||||
book_format text NOT NULL,
|
||||
book_size bigint NOT NULL,
|
||||
cfi text NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
revoked_at timestamp with time zone NULL,
|
||||
download_count integer NOT NULL DEFAULT 0,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT book_shares_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT book_shares_token_hash_key UNIQUE (token_hash),
|
||||
CONSTRAINT book_shares_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_book_shares_user_id ON public.book_shares (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_book_shares_user_id_book_hash ON public.book_shares (user_id, book_hash);
|
||||
|
||||
ALTER TABLE public.book_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY book_shares_select ON public.book_shares
|
||||
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY book_shares_insert ON public.book_shares
|
||||
FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY book_shares_update ON public.book_shares
|
||||
FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY book_shares_delete ON public.book_shares
|
||||
FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- Atomic download_count increment used by the public /download/confirm beacon.
|
||||
-- Runs as SECURITY DEFINER so unauthenticated callers can bump the counter
|
||||
-- (the route gate is "the share is active"; the function enforces that).
|
||||
-- Only increments rows that are active right now — bypasses revoked/expired
|
||||
-- so late-firing analytics pings don't pollute the count.
|
||||
CREATE OR REPLACE FUNCTION public.increment_book_share_download(
|
||||
p_token_hash text,
|
||||
p_now timestamp with time zone
|
||||
) RETURNS void
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
UPDATE public.book_shares
|
||||
SET download_count = download_count + 1
|
||||
WHERE token_hash = p_token_hash
|
||||
AND revoked_at IS NULL
|
||||
AND expires_at > p_now;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.increment_book_share_download(text, timestamp with time zone)
|
||||
TO anon, authenticated, service_role;
|
||||
Reference in New Issue
Block a user