36 Commits

Author SHA1 Message Date
Renovate Bot
79fc921722 Add renovate.json 2025-09-20 07:34:29 +00:00
09f5ca3a99 fix workflow
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m45s
2025-08-28 01:31:03 -07:00
e8a804826a add workflow
Some checks failed
Build and Push Docker Image / docker (push) Has been cancelled
2025-08-28 01:23:33 -07:00
b8cef4d7b3 add postgres integration to save progress across devices 2025-08-28 01:21:28 -07:00
Shaun Tenner
fc88ee9a97 Merge pull request #68 from 4890A/main
set manifest display to fullscreen so PWAs on android can start fullscreen
2025-07-01 09:03:36 +02:00
48
2054f4677c set manifest display to fullscreen 2025-07-01 01:56:47 -04:00
Shaun Tenner
03c7b5e6d9 Merge pull request #64 from Decodatain/image-processing
Enhance image processing settings in AnkiConnect
2025-06-25 20:57:42 +02:00
decodatain
2493555faf Made changes based on ZXY101 code review 2025-06-22 20:16:15 -06:00
decodatain
2fb5d4ae0a Enhance image processing settings in AnkiConnect
- Added height, width, and quality fields to AnkiConnect settings.
- Updated imageToWebp and getCroppedImg functions to accept settings for resizing and quality.
- Integrated settings into Cropper and QuickActions components for improved image handling.

Reason Note:

The recommended settings of 200 and 0.5 make average file sizes of around 5kb. This is about the same filesize as the audio for a given word.

With maximum quality and no file size limit, an image size of 1200x1800 pixels is around 1mb.

This means 1,000 words mined with full sized pictures takes up a 1gb of space on anki.

With the recommended settings, it would take 200,000 words mined to take up a GB of space on anki.

These quality settings can be disabled by settings the max quality to 1 and max width and height to 0. The user is in full control of these changes.
2025-04-26 21:18:55 -06:00
Shaun Tenner
d174113c53 Merge pull request #62 from ZXY101/ZXY101-patch-1
Update +page.svelte
2025-04-15 15:28:26 +02:00
Shaun Tenner
e0b4e3edf0 Update +page.svelte 2025-04-15 15:28:10 +02:00
Shaun Tenner
683a456202 Merge pull request #50 from Gnathonic/improve_compatibility_with_extensions
Improve language extension compatibility
2025-02-07 19:02:32 +02:00
Shaun Tenner
9a4c86870a Merge pull request #49 from v2lmmj04/patch-1
Update underscore prefix for image filenames
2025-02-07 18:38:34 +02:00
Gnathonic
bc5b9ccd16 Add keyed blocks to enhance MangaPage reactivity
Keyed blocks improve DOM update handling. This improves compatibility with some language learning extensions. For example, this prevents Migaku from persisting textboxes from previous pages as one pages through their comics.
2025-01-24 18:19:35 -07:00
v2lmmj04
6c16008aae Change prefix to "mokuro_*"
This was an alternative I was thinking of initially since it better namespaces the files, but wasn't sure about.

I saw this commit ended up going with this approach, so I'm aligning the PR to it in the end:

6d550cbe8a
2025-01-09 19:51:49 -08:00
v2lmmj04
b281052908 Remove underscore prefix for image filenames 2025-01-05 14:51:37 -08:00
Shaun Tenner
52619b1a9b Merge pull request #42 from nyanSpruk/patch-1
Update README.md - typo
2024-11-16 10:18:42 +02:00
Nik Jan Špruk
a30c526a63 Update README.md - typo
Fixed typo from useage to usage.
2024-11-15 20:55:48 +01:00
Shaun Tenner
7fb55f5399 Merge pull request #41 from AnonMiraj/main
Add way to import from other sources
2024-11-14 19:34:43 +02:00
AnonMiraj
6220b6172e Add way to import from other sources 2024-11-13 20:54:10 +02:00
ZXY101
a9f7bca27c Fix title text 2024-08-19 22:02:24 +02:00
Shaun Tenner
0ebeb46171 Merge pull request #23 from kenrick95/kenrick/invert-colors-settings
Settings to invert colors of the images
2024-07-10 23:46:54 +02:00
Kenrick
1fdbdf5185 Settings to invert colors of the images
Useful for novels, for reading in dark mode
2024-07-08 23:15:17 +08:00
ZXY101
109241e584 Add default fullscreen setting 2024-05-25 22:30:16 +02:00
ZXY101
ec632b534b Fix progress 2024-05-18 19:25:19 +02:00
ZXY101
bc7cb721d3 Update loading 2024-05-18 17:26:12 +02:00
ZXY101
5a5dc189fb Improve download 2024-05-18 17:08:40 +02:00
ZXY101
7baf586d3b Merge branch 'main' of https://github.com/ZXY101/z-reader 2024-05-18 16:02:36 +02:00
ZXY101
3e487caad9 Update version 2024-05-18 16:02:19 +02:00
Shaun Tenner
44b8989f2e Merge pull request #17 from ZXY101/gdrive-integration
Add google drive intergration
2024-05-18 15:47:06 +02:00
ZXY101
b4573fd955 Complete Gdrive integration 2024-05-18 15:44:16 +02:00
ZXY101
08eee8f62c Add title to cloud route 2024-03-26 02:17:47 +02:00
ZXY101
37c4aa1572 Update drive styling 2024-03-26 02:16:29 +02:00
ZXY101
3b237e15f5 Update scope 2024-03-26 01:57:17 +02:00
ZXY101
edea5aea08 Update scope 2024-03-26 01:55:39 +02:00
Shaun Tenner
940313310b Merge pull request #13 from ZXY101/google-drive
Add Google drive functionality
2024-03-26 01:23:40 +02:00
30 changed files with 1504 additions and 462 deletions

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.git
.gitignore
README.md
.env
.nyc_output
coverage
.nyc_output
.coverage
coverage.json
*.lcov
.cache
.parcel-cache
.next
.nuxt
dist
build
.DS_Store
*.tgz
*.tar.gz
.vscode
.idea
*.swp
*.swo
*~

View File

@@ -0,0 +1,64 @@
name: Build and Push Docker Image
on:
push:
branches:
- "main"
tags:
- "v*"
workflow_dispatch:
jobs:
docker:
runs-on: ubuntu-latest
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Ensure full history and all tags are available
fetch-depth: 0
- name: Set up QEMU (multi-arch)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.suda.codes/${{ github.repository }}
tags: |
type=ref,event=tag
- name: Determine latest Git tag
id: latest
shell: bash
run: |
set -euo pipefail
# Fetch tags in case the runner's mirror is stale
git fetch --tags --force --quiet || true
if tag=$(git describe --tags --abbrev=0 2>/dev/null); then
echo "tag=$tag" >> "$GITHUB_OUTPUT"
else
# Fallback when no tags exist
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: gitea.suda.codes
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64
tags: |
${{ steps.meta.outputs.tags }}
gitea.suda.codes/${{ github.repository }}:${{ steps.latest.outputs.tag }}

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image, copy all the files and run the app
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 svelte
COPY --from=builder --chown=svelte:nodejs /app/build ./build
COPY --from=builder --chown=svelte:nodejs /app/package.json ./package.json
COPY --from=builder --chown=svelte:nodejs /app/node_modules ./node_modules
USER svelte
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["npm", "start"]

View File

@@ -10,7 +10,7 @@ https://github.com/ZXY101/mokuro-reader/assets/39561296/45a214a8-3f69-461c-87d7-
- Anki connect integration & image cropping - Anki connect integration & image cropping
- Installation and offline support - Installation and offline support
## Useage: ## Usage:
You can find the reader hosted [here](https://reader.mokuro.app/). You can find the reader hosted [here](https://reader.mokuro.app/).
To import your manga, process it with mokuro and then upload your manga along with the generated `.mokuro` file. To import your manga, process it with mokuro and then upload your manga along with the generated `.mokuro` file.

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://mokuro:password@postgres:5432/mokuro
- HOSTNAME=0.0.0.0
- PORT=3000
depends_on:
- postgres
restart: unless-stopped
networks:
- mokuro-network
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_DB=mokuro
- POSTGRES_USER=mokuro
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
networks:
- mokuro-network
volumes:
postgres_data:
networks:
mokuro-network:
driver: bridge

980
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
{ {
"name": "z-reader", "name": "z-reader",
"version": "0.9.0", "version": "0.9.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .", "lint": "prettier --plugin-search-dir . --check . && eslint .",
@@ -37,12 +38,15 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^1.3.1",
"@types/gapi": "^0.0.47", "@types/gapi": "^0.0.47",
"@types/google.accounts": "^0.0.14", "@types/google.accounts": "^0.0.14",
"@types/google.picker": "^0.0.42",
"@vercel/analytics": "^1.1.0", "@vercel/analytics": "^1.1.0",
"@zip.js/zip.js": "^2.7.20", "@zip.js/zip.js": "^2.7.20",
"dexie": "^4.0.1-alpha.25", "dexie": "^4.0.1-alpha.25",
"panzoom": "^9.4.3", "panzoom": "^9.4.3",
"pg": "^8.16.3",
"svelte-easy-crop": "^2.0.1" "svelte-easy-crop": "^2.0.1"
} }
} }

6
renovate.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -1,6 +1,7 @@
import { showSnackbar } from '$lib/util'; import { showSnackbar } from '$lib/util';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { blobToBase64 } from '.'; import { blobToBase64, imageResize } from '.';
import { Settings } from '$lib/settings';
type CropperModal = { type CropperModal = {
open: boolean; open: boolean;
@@ -34,7 +35,7 @@ function getRadianAngle(degreeValue: number) {
export type Pixels = { width: number; height: number; x: number; y: number } export type Pixels = { width: number; height: number; x: number; y: number }
export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, rotation = 0) { export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, settings: Settings, rotation = 0 ) {
const image = await createImage(imageSrc); const image = await createImage(imageSrc);
const canvas = new OffscreenCanvas(image.width, image.height); const canvas = new OffscreenCanvas(image.width, image.height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -72,7 +73,8 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, rotatio
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y) Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
); );
const blob = await canvas.convertToBlob({ type: 'image/webp' }); await imageResize(canvas, ctx, settings.ankiConnectSettings.widthField, settings.ankiConnectSettings.heightField);
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: settings.ankiConnectSettings.qualityField });
return await blobToBase64(blob) return await blobToBase64(blob)
} }

View File

@@ -1,4 +1,4 @@
import { settings } from "$lib/settings"; import { Settings, settings } from "$lib/settings";
import { showSnackbar } from "$lib/util" import { showSnackbar } from "$lib/util"
import { get } from "svelte/store"; import { get } from "svelte/store";
@@ -50,20 +50,44 @@ export async function blobToBase64(blob: Blob) {
}); });
} }
export async function imageToWebp(source: File) { export async function imageToWebp(source: File, settings: Settings) {
const image = await createImageBitmap(source); const image = await createImageBitmap(source);
const canvas = new OffscreenCanvas(image.width, image.height); const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
if (context) { if (context) {
context.drawImage(image, 0, 0); context.drawImage(image, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/webp' }); await imageResize(canvas, context, settings.ankiConnectSettings.widthField, settings.ankiConnectSettings.heightField);
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: settings.ankiConnectSettings.qualityField });
image.close(); image.close();
return await blobToBase64(blob); return await blobToBase64(blob);
} }
} }
export async function imageResize(canvas: OffscreenCanvas, ctx: OffscreenCanvasRenderingContext2D, maxWidth: number, maxHeight: number): Promise<OffscreenCanvas> {
return new Promise((resolve, reject) => {
const widthRatio = maxWidth <= 0 ? 1 : maxWidth / canvas.width;
const heightRatio = maxHeight <= 0 ? 1 : maxHeight / canvas.height;
const ratio = Math.min(1, Math.min(widthRatio, heightRatio));
if (ratio < 1) {
const newWidth = canvas.width * ratio;
const newHeight = canvas.height * ratio;
createImageBitmap(canvas, { resizeWidth: newWidth, resizeHeight: newHeight, resizeQuality: 'high' })
.then((sprite) => {
canvas.width = newWidth;
canvas.height = newHeight;
ctx.drawImage(sprite, 0, 0);
resolve(canvas);
})
.catch((e) => reject(e));
} else {
resolve(canvas);
}
});
}
export async function updateLastCard(imageData: string | null | undefined, sentence?: string) { export async function updateLastCard(imageData: string | null | undefined, sentence?: string) {
const { const {
overwriteImage, overwriteImage,
@@ -102,7 +126,7 @@ export async function updateLastCard(imageData: string | null | undefined, sente
id, id,
fields, fields,
picture: { picture: {
filename: `_${id}.webp`, filename: `mokuro_${id}.webp`,
data: imageData.split(';base64,')[1], data: imageData.split(';base64,')[1],
fields: [pictureField], fields: [pictureField],
}, },

View File

@@ -18,7 +18,9 @@
class="object-contain sm:w-[250px] sm:h-[350px] bg-black border-gray-900 border" class="object-contain sm:w-[250px] sm:h-[350px] bg-black border-gray-900 border"
/> />
{/if} {/if}
<p class="font-semibold">{manga.mokuroData.title}</p> <p class="font-semibold sm:w-[250px] line-clamp-1">
{manga.mokuroData.title}
</p>
</div> </div>
</a> </a>
{/if} {/if}

View File

@@ -37,7 +37,7 @@
<div class="flex md:order-2 gap-5"> <div class="flex md:order-2 gap-5">
<UserSettingsSolid class="hover:text-primary-700" on:click={openSettings} /> <UserSettingsSolid class="hover:text-primary-700" on:click={openSettings} />
<UploadSolid class="hover:text-primary-700" on:click={() => (uploadModalOpen = true)} /> <UploadSolid class="hover:text-primary-700" on:click={() => (uploadModalOpen = true)} />
<!-- <CloudArrowUpOutline class="hover:text-primary-700" on:click={() => goto('/cloud')} /> --> <CloudArrowUpOutline class="hover:text-primary-700" on:click={() => goto('/cloud')} />
</div> </div>
</Navbar> </Navbar>
</div> </div>

View File

@@ -37,7 +37,7 @@
async function onCrop() { async function onCrop() {
if ($cropperStore?.image && pixels) { if ($cropperStore?.image && pixels) {
loading = true; loading = true;
const imageData = await getCroppedImg($cropperStore.image, pixels); const imageData = await getCroppedImg($cropperStore.image, pixels, $settings);
updateLastCard(imageData, $cropperStore.sentence); updateLastCard(imageData, $cropperStore.sentence);
close(); close();
} }

View File

@@ -40,7 +40,7 @@
showCropper(URL.createObjectURL(src)); showCropper(URL.createObjectURL(src));
} else { } else {
promptConfirmation('Add image to last created anki card?', async () => { promptConfirmation('Add image to last created anki card?', async () => {
const imageData = await imageToWebp(src); const imageData = await imageToWebp(src, $settings);
updateLastCard(imageData); updateLastCard(imageData);
}); });
} }

View File

@@ -23,6 +23,7 @@
import { getCharCount } from '$lib/util/count-chars'; import { getCharCount } from '$lib/util/count-chars';
import QuickActions from './QuickActions.svelte'; import QuickActions from './QuickActions.svelte';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
import { onMount } from 'svelte';
// TODO: Refactor this whole mess // TODO: Refactor this whole mess
export let volumeSettings: VolumeSettings; export let volumeSettings: VolumeSettings;
@@ -220,7 +221,17 @@
} }
} }
onMount(() => {
if ($settings.defaultFullscreen) {
document.documentElement.requestFullscreen();
}
});
beforeNavigate(() => { beforeNavigate(() => {
if (document.exitFullscreen) {
document.exitFullscreen();
}
if (volume) { if (volume) {
const { charCount, lineCount } = getCharCount(pages, page); const { charCount, lineCount } = getCharCount(pages, page);
@@ -329,14 +340,17 @@
<div <div
class="flex flex-row" class="flex flex-row"
class:flex-row-reverse={!volumeSettings.rightToLeft} class:flex-row-reverse={!volumeSettings.rightToLeft}
style:filter={`invert(${$settings.invertColors ? 1 : 0})`}
on:dblclick={onDoubleTap} on:dblclick={onDoubleTap}
role="none" role="none"
id="manga-panel" id="manga-panel"
> >
{#if showSecondPage()} {#key page}
<MangaPage page={pages[index + 1]} src={Object.values(volume?.files)[index + 1]} /> {#if showSecondPage()}
{/if} <MangaPage page={pages[index + 1]} src={Object.values(volume?.files)[index + 1]} />
<MangaPage page={pages[index]} src={Object.values(volume?.files)[index]} /> {/if}
<MangaPage page={pages[index]} src={Object.values(volume?.files)[index]} />
{/key}
</div> </div>
</Panzoom> </Panzoom>
</div> </div>

View File

@@ -54,7 +54,7 @@
showCropper(URL.createObjectURL(src), sentence); showCropper(URL.createObjectURL(src), sentence);
} else { } else {
promptConfirmation('Add image to last created anki card?', async () => { promptConfirmation('Add image to last created anki card?', async () => {
const imageData = await imageToWebp(src); const imageData = await imageToWebp(src, $settings);
updateLastCard(imageData, sentence); updateLastCard(imageData, sentence);
}); });
} }

View File

@@ -13,6 +13,10 @@
let pictureField = $settings.ankiConnectSettings.pictureField; let pictureField = $settings.ankiConnectSettings.pictureField;
let sentenceField = $settings.ankiConnectSettings.sentenceField; let sentenceField = $settings.ankiConnectSettings.sentenceField;
let heightField = $settings.ankiConnectSettings.heightField;
let widthField = $settings.ankiConnectSettings.widthField;
let qualityField = $settings.ankiConnectSettings.qualityField;
let triggerMethod = $settings.ankiConnectSettings.triggerMethod; let triggerMethod = $settings.ankiConnectSettings.triggerMethod;
const triggerOptions = [ const triggerOptions = [
@@ -90,5 +94,40 @@
/> />
</Label> </Label>
</div> </div>
<hr>
<h4>Quality Settings</h4>
<Helper>Allows you to customize the file size stored on your devices</Helper>
<div>
<Label>Max Height (0 = Ignore; 200 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={heightField}
on:change={() => {updateAnkiSetting('heightField', heightField); if (heightField < 0) heightField = 0;}}
min={0}
/>
</div>
<div>
<Label>Max Width (0 = Ignore; 200 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={widthField}
on:change={() => {updateAnkiSetting('widthField', widthField); if (widthField < 0) widthField = 0;}}
min={0}
/>
</div>
<div>
<Label>Quality (Between 0 and 1; 0.5 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={qualityField}
on:change={() => updateAnkiSetting('qualityField', qualityField)}
min={0}
max={1}
step="0.1"
/>
</div>
</div> </div>
</AccordionItem> </AccordionItem>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { AccordionItem, Button, Label, Input, Toggle } from 'flowbite-svelte';
import { syncConfig, pullAll, pushAll } from '$lib/settings/sync';
import { get } from 'svelte/store';
let keyLocal: string = get(syncConfig).key || '';
function saveKey() {
syncConfig.update((cfg) => ({ ...cfg, key: keyLocal || null }));
}
function toggleAuto() {
syncConfig.update((cfg) => ({ ...cfg, auto: !cfg.auto }));
}
function toggleNotifications() {
syncConfig.update((cfg) => ({ ...cfg, notifications: !cfg.notifications }));
}
async function onPull() {
try {
await pullAll();
} catch (e) {}
}
async function onPush() {
try {
await pushAll();
} catch (e) {}
}
</script>
<AccordionItem>
<span slot="header">Cloud Sync</span>
<div class="flex flex-col gap-4">
<div>
<Label>Sync Key</Label>
<div class="flex gap-2">
<Input class="flex-1" bind:value={keyLocal} placeholder="Enter a shared key" />
<Button on:click={saveKey}>Save</Button>
</div>
</div>
<div class="flex items-center gap-3">
<Toggle checked={get(syncConfig).auto} on:change={toggleAuto} />
<Label>Auto Sync</Label>
</div>
<div class="flex items-center gap-3">
<Toggle checked={get(syncConfig).notifications} on:change={toggleNotifications} />
<Label>Show Sync Notifications</Label>
</div>
<div class="flex gap-2">
<Button on:click={onPull} outline>Pull from cloud</Button>
<Button on:click={onPush} outline>Push to cloud</Button>
</div>
<p class="text-sm opacity-80">Use the same sync key across devices to share progress and settings. Manga files remain local or in Google Drive.</p>
</div>
</AccordionItem>

View File

@@ -3,6 +3,7 @@
import { Toggle } from 'flowbite-svelte'; import { Toggle } from 'flowbite-svelte';
$: toggles = [ $: toggles = [
{ key: 'defaultFullscreen', text: 'Open reader in fullscreen', value: $settings.defaultFullscreen },
{ key: 'textEditable', text: 'Editable text', value: $settings.textEditable }, { key: 'textEditable', text: 'Editable text', value: $settings.textEditable },
{ key: 'textBoxBorders', text: 'Text box borders', value: $settings.textBoxBorders }, { key: 'textBoxBorders', text: 'Text box borders', value: $settings.textBoxBorders },
{ key: 'displayOCR', text: 'OCR enabled', value: $settings.displayOCR }, { key: 'displayOCR', text: 'OCR enabled', value: $settings.displayOCR },
@@ -12,7 +13,8 @@
{ key: 'bounds', text: 'Bounds', value: $settings.bounds }, { key: 'bounds', text: 'Bounds', value: $settings.bounds },
{ key: 'mobile', text: 'Mobile', value: $settings.mobile }, { key: 'mobile', text: 'Mobile', value: $settings.mobile },
{ key: 'showTimer', text: 'Show timer', value: $settings.showTimer }, { key: 'showTimer', text: 'Show timer', value: $settings.showTimer },
{ key: 'quickActions', text: 'Show quick actions', value: $settings.quickActions } { key: 'quickActions', text: 'Show quick actions', value: $settings.quickActions },
{ key: 'invertColors', text: 'Invert colors of the images', value: $settings.invertColors }
] as { key: SettingsKey; text: string; value: any }[]; ] as { key: SettingsKey; text: string; value: any }[];
</script> </script>

View File

@@ -13,6 +13,7 @@
import VolumeSettings from './Volume/VolumeSettings.svelte'; import VolumeSettings from './Volume/VolumeSettings.svelte';
import About from './About.svelte'; import About from './About.svelte';
import QuickAccess from './QuickAccess.svelte'; import QuickAccess from './QuickAccess.svelte';
import CloudSync from './CloudSync.svelte';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
let transitionParams = { let transitionParams = {
@@ -33,7 +34,8 @@
} }
beforeNavigate((nav) => { beforeNavigate((nav) => {
if (!hidden) { // Only block navigation if settings drawer is open and we're trying to navigate away
if (!hidden && nav.type !== 'popstate') {
nav.cancel(); nav.cancel();
hidden = true; hidden = true;
} }
@@ -63,6 +65,7 @@
<VolumeDefaults /> <VolumeDefaults />
{/if} {/if}
<Profiles {onClose} /> <Profiles {onClose} />
<CloudSync />
<ReaderSettings /> <ReaderSettings />
<AnkiConnectSettings /> <AnkiConnectSettings />
<CatalogSettings /> <CatalogSettings />

View File

@@ -1 +1 @@
export const READER_VERSION = '0.9.0' export const READER_VERSION = '0.9.1'

77
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,77 @@
import { Pool } from 'pg';
import { env } from '$env/dynamic/private';
let pool: Pool | undefined;
function getPool(): Pool {
if (!pool) {
const connectionString = env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is not set');
}
pool = new Pool({ connectionString, max: 5 });
}
return pool;
}
export async function ensureSchema(): Promise<void> {
const client = await getPool().connect();
try {
await client.query(
`create table if not exists user_sync (
sync_key text primary key,
profiles jsonb,
current_profile text,
misc_settings jsonb,
volumes jsonb,
updated_at timestamptz default now()
)`
);
} finally {
client.release();
}
}
export type SyncRow = {
sync_key: string;
profiles: any | null;
current_profile: string | null;
misc_settings: any | null;
volumes: any | null;
updated_at: string;
};
export async function getSyncRow(syncKey: string): Promise<SyncRow | null> {
await ensureSchema();
const { rows } = await getPool().query('select * from user_sync where sync_key = $1', [syncKey]);
return rows[0] ?? null;
}
export async function upsertSyncRow(
syncKey: string,
data: Partial<Omit<SyncRow, 'sync_key' | 'updated_at'>>
): Promise<SyncRow> {
await ensureSchema();
const { rows } = await getPool().query(
`insert into user_sync (sync_key, profiles, current_profile, misc_settings, volumes)
values ($1, $2, $3, $4, $5)
on conflict (sync_key) do update set
profiles = coalesce(excluded.profiles, user_sync.profiles),
current_profile = coalesce(excluded.current_profile, user_sync.current_profile),
misc_settings = coalesce(excluded.misc_settings, user_sync.misc_settings),
volumes = coalesce(excluded.volumes, user_sync.volumes),
updated_at = now()
returning *`,
[
syncKey,
data.profiles ?? null,
data.current_profile ?? null,
data.misc_settings ?? null,
data.volumes ?? null
]
);
return rows[0];
}

View File

@@ -1,3 +1,4 @@
export * from './volume-data' export * from './volume-data'
export * from './settings' export * from './settings'
export * from './misc' export * from './misc'
export * from './sync'

View File

@@ -28,6 +28,9 @@ export type AnkiConnectSettings = {
enabled: boolean; enabled: boolean;
pictureField: string; pictureField: string;
sentenceField: string; sentenceField: string;
heightField: number;
widthField: number;
qualityField: number;
cropImage: boolean; cropImage: boolean;
overwriteImage: boolean; overwriteImage: boolean;
grabSentence: boolean; grabSentence: boolean;
@@ -41,6 +44,7 @@ export type VolumeDefaults = {
} }
export type Settings = { export type Settings = {
defaultFullscreen: boolean;
textEditable: boolean; textEditable: boolean;
textBoxBorders: boolean; textBoxBorders: boolean;
displayOCR: boolean; displayOCR: boolean;
@@ -56,6 +60,7 @@ export type Settings = {
quickActions: boolean; quickActions: boolean;
fontSize: FontSize; fontSize: FontSize;
zoomDefault: ZoomModes; zoomDefault: ZoomModes;
invertColors: boolean;
volumeDefaults: VolumeDefaults; volumeDefaults: VolumeDefaults;
ankiConnectSettings: AnkiConnectSettings; ankiConnectSettings: AnkiConnectSettings;
}; };
@@ -67,6 +72,7 @@ export type AnkiSettingsKey = keyof AnkiConnectSettings;
export type VolumeDefaultsKey = keyof VolumeDefaults; export type VolumeDefaultsKey = keyof VolumeDefaults;
const defaultSettings: Settings = { const defaultSettings: Settings = {
defaultFullscreen: false,
displayOCR: true, displayOCR: true,
textEditable: false, textEditable: false,
textBoxBorders: false, textBoxBorders: false,
@@ -82,6 +88,7 @@ const defaultSettings: Settings = {
quickActions: true, quickActions: true,
fontSize: 'auto', fontSize: 'auto',
zoomDefault: 'zoomFitToScreen', zoomDefault: 'zoomFitToScreen',
invertColors: false,
volumeDefaults: { volumeDefaults: {
singlePageView: false, singlePageView: false,
rightToLeft: true, rightToLeft: true,
@@ -94,6 +101,9 @@ const defaultSettings: Settings = {
overwriteImage: true, overwriteImage: true,
pictureField: 'Picture', pictureField: 'Picture',
sentenceField: 'Sentence', sentenceField: 'Sentence',
heightField: 0,
widthField: 0,
qualityField: 1,
triggerMethod: 'both' triggerMethod: 'both'
} }
}; };

109
src/lib/settings/sync.ts Normal file
View File

@@ -0,0 +1,109 @@
import { browser } from '$app/environment';
import { derived, get, writable } from 'svelte/store';
import { debounce, showSnackbar } from '$lib/util';
import { currentProfile, profiles } from './settings';
import { miscSettings } from './misc';
import { volumes } from './volume-data';
export type SyncConfig = {
key: string | null;
auto: boolean;
notifications: boolean;
};
const stored = browser ? window.localStorage.getItem('syncConfig') : undefined;
const initial: SyncConfig = stored ? JSON.parse(stored) : { key: null, auto: false, notifications: true };
export const syncConfig = writable<SyncConfig>(initial);
syncConfig.subscribe((value) => {
if (browser) {
window.localStorage.setItem('syncConfig', JSON.stringify(value));
}
});
export const isSyncEnabled = derived(syncConfig, ($cfg) => Boolean($cfg.key));
async function callApi(method: 'GET' | 'POST', payload?: any) {
if (!browser) return null;
const url = method === 'GET' ? `/api/sync?key=${encodeURIComponent(get(syncConfig).key || '')}` : '/api/sync';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'POST' ? JSON.stringify(payload) : undefined
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function pullAll() {
const cfg = get(syncConfig);
if (!cfg.key) throw new Error('No sync key configured');
const data = await callApi('GET');
if (!data) return;
if (data.profiles) {
profiles.set(data.profiles);
}
if (data.current_profile) {
currentProfile.set(data.current_profile);
}
if (data.misc_settings) {
miscSettings.set(data.misc_settings);
}
if (data.volumes) {
volumes.set(data.volumes);
}
if (cfg.notifications) {
showSnackbar('Pulled from server');
}
}
export async function pushAll() {
const cfg = get(syncConfig);
if (!cfg.key) throw new Error('No sync key configured');
const payload = {
key: cfg.key,
profiles: get(profiles),
current_profile: get(currentProfile),
misc_settings: get(miscSettings),
volumes: get(volumes)
};
await callApi('POST', payload);
if (cfg.notifications) {
showSnackbar('Pushed to server');
}
}
function setupAutoSync() {
if (!browser) return;
let unsubscribeFns: Array<() => void> = [];
function resubscribe() {
unsubscribeFns.forEach((fn) => fn());
unsubscribeFns = [];
if (!get(syncConfig).auto || !get(syncConfig).key) return;
const schedulePush = () => debounce(() => {
pushAll().catch((error) => {
console.error('Auto-sync failed:', error);
});
}, 300);
unsubscribeFns.push(profiles.subscribe(() => schedulePush()));
unsubscribeFns.push(currentProfile.subscribe(() => schedulePush()));
unsubscribeFns.push(miscSettings.subscribe(() => schedulePush()));
unsubscribeFns.push(volumes.subscribe(() => schedulePush()));
}
resubscribe();
unsubscribeFns.push(syncConfig.subscribe(() => resubscribe()));
}
if (browser) {
setupAutoSync();
}

View File

@@ -0,0 +1,29 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { getSyncRow, upsertSyncRow } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
const syncKey = url.searchParams.get('key');
if (!syncKey) {
return json({ error: 'Missing key' }, { status: 400 });
}
const row = await getSyncRow(syncKey);
if (!row) {
return json({ profiles: null, current_profile: null, misc_settings: null, volumes: null, updated_at: null });
}
return json(row);
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const { key, profiles, current_profile, misc_settings, volumes } = body ?? {};
if (!key) {
return json({ error: 'Missing key' }, { status: 400 });
}
const row = await upsertSyncRow(key, { profiles, current_profile, misc_settings, volumes });
return json(row);
};

View File

@@ -2,7 +2,7 @@
import { processFiles } from '$lib/upload'; import { processFiles } from '$lib/upload';
import Loader from '$lib/components/Loader.svelte'; import Loader from '$lib/components/Loader.svelte';
import { formatBytes, showSnackbar, uploadFile } from '$lib/util'; import { formatBytes, showSnackbar, uploadFile } from '$lib/util';
import { Button, Frame, Listgroup, ListgroupItem } from 'flowbite-svelte'; import { Button, P, Progressbar } from 'flowbite-svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { promptConfirmation } from '$lib/util'; import { promptConfirmation } from '$lib/util';
import { GoogleSolid } from 'flowbite-svelte-icons'; import { GoogleSolid } from 'flowbite-svelte-icons';
@@ -12,7 +12,7 @@
const API_KEY = import.meta.env.VITE_GDRIVE_API_KEY; const API_KEY = import.meta.env.VITE_GDRIVE_API_KEY;
const DISCOVERY_DOC = 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'; const DISCOVERY_DOC = 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest';
const SCOPES = 'https://www.googleapis.com/auth/drive'; const SCOPES = 'https://www.googleapis.com/auth/drive.file';
const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'; const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder';
const READER_FOLDER = 'mokuro-reader'; const READER_FOLDER = 'mokuro-reader';
@@ -22,63 +22,70 @@
const type = 'application/json'; const type = 'application/json';
let tokenClient: any; let tokenClient: any;
let zips: gapi.client.drive.File[]; let accessToken = '';
let loadingMessage = ''; let gapiLoaded = false;
let googleLoaded = false;
let readerFolderId = ''; let readerFolderId = '';
let volumeDataId = ''; let volumeDataId = '';
let profilesId = ''; let profilesId = '';
async function fetchZips(folderId: string) { let loadingMessage = '';
const { result } = await gapi.client.drive.files.list({ let errorMessage = '';
q: `'${folderId}' in parents and (mimeType='${FOLDER_MIME_TYPE}' or (fileExtension='zip' or fileExtension='cbz'))`,
fields: 'files(id, name, mimeType, size)'
});
if (!result.files) return; let completed = 0;
let totalSize = 0;
$: progress = Math.floor((completed / totalSize) * 100).toString();
let zipFiles: gapi.client.drive.File[] = []; function xhrDownloadFileId(fileId: string) {
return new Promise<Blob>((resolve, reject) => {
for (const file of result.files) { if (!gapiLoaded || !gapi.auth.getToken()) {
if (!file.id) continue; reject(new Error('Not authenticated'));
return;
if (file.mimeType === FOLDER_MIME_TYPE) {
zipFiles = [...zipFiles, ...((await fetchZips(file.id)) || [])];
} else {
zipFiles.push(file);
} }
}
return zipFiles; const { access_token } = gapi.auth.getToken();
} const xhr = new XMLHttpRequest();
async function downloadFile(fileId: string) { completed = 0;
loadingMessage = 'Downloading from drive'; totalSize = 0;
const { body, headers } = await gapi.client.drive.files.get({ xhr.open('GET', `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`);
fileId, xhr.setRequestHeader('Authorization', `Bearer ${access_token}`);
alt: 'media' xhr.responseType = 'blob';
xhr.onprogress = ({ loaded, total }) => {
loadingMessage = '';
completed = loaded;
totalSize = total;
};
xhr.onabort = (event) => {
console.warn(`xhr ${fileId}: download aborted at ${event.loaded} of ${event.total}`);
showSnackbar('Download failed');
reject(new Error('Download aborted'));
};
xhr.onerror = (event) => {
console.error(`xhr ${fileId}: download error at ${event.loaded} of ${event.total}`);
showSnackbar('Download failed');
reject(new Error('Error downloading file'));
};
xhr.onload = () => {
completed = 0;
totalSize = 0;
resolve(xhr.response);
};
xhr.ontimeout = (event) => {
console.warn(`xhr ${fileId}: download timeout after ${event.loaded} of ${event.total}`);
showSnackbar('Download timed out');
reject(new Error('Timout downloading file'));
};
xhr.send();
}); });
const type = headers?.['Content-Type'] || '';
const blob = new Blob([new Uint8Array(body.length).map((_, i) => body.charCodeAt(i))], {
type
});
const file = new File([blob], fileId + '.zip');
loadingMessage = 'Adding to catalog';
await processFiles([file]);
loadingMessage = '';
}
function onClick({ id, name }: gapi.client.drive.File) {
if (id) {
promptConfirmation(`Would you like to download and add ${name} to your collection?`, () => {
downloadFile(id);
});
}
} }
async function connectDrive(resp?: any) { async function connectDrive(resp?: any) {
@@ -86,50 +93,64 @@
throw resp; throw resp;
} }
accessToken = resp?.access_token;
loadingMessage = 'Connecting to drive'; loadingMessage = 'Connecting to drive';
const { result: readerFolderRes } = await gapi.client.drive.files.list({ try {
q: `mimeType='application/vnd.google-apps.folder' and name='${READER_FOLDER}'`, const { result: readerFolderRes } = await gapi.client.drive.files.list({
fields: 'files(id)' q: `mimeType='application/vnd.google-apps.folder' and name='${READER_FOLDER}'`,
}); fields: 'files(id)'
if (readerFolderRes.files?.length === 0) {
const { result: createReaderFolderRes } = await gapi.client.drive.files.create({
resource: { mimeType: FOLDER_MIME_TYPE, name: READER_FOLDER },
fields: 'id'
}); });
readerFolderId = createReaderFolderRes.id || ''; if (readerFolderRes.files?.length === 0) {
} else { const { result: createReaderFolderRes } = await gapi.client.drive.files.create({
const id = readerFolderRes.files?.[0]?.id || ''; resource: { mimeType: FOLDER_MIME_TYPE, name: READER_FOLDER },
zips = [...((await fetchZips(id)) || [])]; fields: 'id'
});
readerFolderId = id || ''; readerFolderId = createReaderFolderRes.id || '';
} else {
const id = readerFolderRes.files?.[0]?.id || '';
readerFolderId = id || '';
}
const { result: volumeDataRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${VOLUME_DATA_FILE}'`,
fields: 'files(id, name)'
});
if (volumeDataRes.files?.length !== 0) {
volumeDataId = volumeDataRes.files?.[0].id || '';
}
const { result: profilesRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${PROFILES_FILE}'`,
fields: 'files(id, name)'
});
if (profilesRes.files?.length !== 0) {
profilesId = profilesRes.files?.[0].id || '';
}
loadingMessage = '';
errorMessage = '';
if (accessToken) {
showSnackbar('Connected to Google Drive');
}
} catch (error) {
console.error('Error connecting to Drive:', error);
errorMessage = 'Failed to connect to Google Drive. Please check your credentials.';
loadingMessage = '';
} }
const { result: volumeDataRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${VOLUME_DATA_FILE}'`,
fields: 'files(id, name)'
});
if (volumeDataRes.files?.length !== 0) {
volumeDataId = volumeDataRes.files?.[0].id || '';
}
const { result: profilesRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${PROFILES_FILE}'`,
fields: 'files(id, name)'
});
if (profilesRes.files?.length !== 0) {
profilesId = profilesRes.files?.[0].id || '';
}
loadingMessage = '';
showSnackbar('Connected to Google Drive');
} }
function signIn() { function signIn() {
if (!gapiLoaded || !googleLoaded) {
errorMessage = 'Google APIs not loaded yet. Please wait and try again.';
return;
}
if (gapi.client.getToken() === null) { if (gapi.client.getToken() === null) {
tokenClient.requestAccessToken({ prompt: 'consent' }); tokenClient.requestAccessToken({ prompt: 'consent' });
} else { } else {
@@ -138,37 +159,99 @@
} }
onMount(() => { onMount(() => {
gapi.load('client', async () => { // Check if environment variables are set
await gapi.client.init({ if (!CLIENT_ID || !API_KEY) {
apiKey: API_KEY, errorMessage = 'Google Drive integration not configured. Please set VITE_GDRIVE_CLIENT_ID and VITE_GDRIVE_API_KEY environment variables.';
discoveryDocs: [DISCOVERY_DOC] return;
}
// Load Google APIs
if (typeof gapi !== 'undefined') {
gapiLoaded = true;
gapi.load('client', async () => {
try {
await gapi.client.init({
apiKey: API_KEY,
discoveryDocs: [DISCOVERY_DOC]
});
} catch (error) {
console.error('Error initializing gapi client:', error);
errorMessage = 'Failed to initialize Google Drive client.';
}
}); });
if (gapi.client.getToken() !== null) { gapi.load('picker', () => {});
loadingMessage = 'Connecting to drive'; } else {
connectDrive(); errorMessage = 'Google APIs not loaded. Please check your internet connection.';
} }
});
tokenClient = google.accounts.oauth2.initTokenClient({ if (typeof google !== 'undefined') {
client_id: CLIENT_ID, googleLoaded = true;
scope: SCOPES, tokenClient = google.accounts.oauth2.initTokenClient({
callback: connectDrive client_id: CLIENT_ID,
}); scope: SCOPES,
callback: connectDrive
});
} else {
errorMessage = 'Google OAuth not loaded. Please check your internet connection.';
}
}); });
function createPicker() {
if (!googleLoaded || !accessToken) {
showSnackbar('Not connected to Google Drive');
return;
}
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes('application/zip,application/x-zip-compressed')
.setMode(google.picker.DocsViewMode.LIST)
.setIncludeFolders(true)
.setParent(readerFolderId);
const picker = new google.picker.PickerBuilder()
.addView(docsView)
.setOAuthToken(accessToken)
.setAppId(CLIENT_ID)
.setDeveloperKey(API_KEY)
.enableFeature(google.picker.Feature.NAV_HIDDEN)
.setCallback(pickerCallback)
.build();
picker.setVisible(true);
}
async function pickerCallback(data: google.picker.ResponseObject) {
try {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
loadingMessage = 'Downloading from drive...';
const docs = data[google.picker.Response.DOCUMENTS];
const blob = await xhrDownloadFileId(docs[0].id);
loadingMessage = 'Adding to catalog...';
const file = new File([blob], docs[0].name);
await processFiles([file]);
loadingMessage = '';
}
} catch (error) {
showSnackbar('Something went wrong');
loadingMessage = '';
console.error(error);
}
}
async function onUploadVolumeData() { async function onUploadVolumeData() {
const metadata = { const metadata = {
mimeType: type, mimeType: type,
name: VOLUME_DATA_FILE, name: VOLUME_DATA_FILE,
parents: [volumeDataId ? null : readerFolderId] parents: [volumeDataId ? null : readerFolderId]
}; };
const { access_token } = gapi.auth.getToken();
loadingMessage = 'Uploading volume data'; loadingMessage = 'Uploading volume data';
const res = await uploadFile({ const res = await uploadFile({
accessToken: access_token, accessToken,
fileId: volumeDataId, fileId: volumeDataId,
metadata, metadata,
localStorageId: 'volumes', localStorageId: 'volumes',
@@ -178,7 +261,9 @@
volumeDataId = res.id; volumeDataId = res.id;
loadingMessage = ''; loadingMessage = '';
showSnackbar('Volume data uploaded'); if (volumeDataId) {
showSnackbar('Volume data uploaded');
}
} }
async function onUploadProfiles() { async function onUploadProfiles() {
@@ -187,12 +272,11 @@
name: PROFILES_FILE, name: PROFILES_FILE,
parents: [profilesId ? null : readerFolderId] parents: [profilesId ? null : readerFolderId]
}; };
const { access_token } = gapi.auth.getToken();
loadingMessage = 'Uploading profiles'; loadingMessage = 'Uploading profiles';
const res = await uploadFile({ const res = await uploadFile({
accessToken: access_token, accessToken,
fileId: profilesId, fileId: profilesId,
metadata, metadata,
localStorageId: 'profiles', localStorageId: 'profiles',
@@ -202,7 +286,9 @@
profilesId = res.id; profilesId = res.id;
loadingMessage = ''; loadingMessage = '';
showSnackbar('Profiles uploaded'); if (profilesId) {
showSnackbar('Profiles uploaded');
}
} }
async function onDownloadVolumeData() { async function onDownloadVolumeData() {
@@ -248,16 +334,36 @@
} }
</script> </script>
<svelte:head>
<title>Cloud</title>
</svelte:head>
<div class="p-2 h-[90svh]"> <div class="p-2 h-[90svh]">
{#if loadingMessage} {#if errorMessage}
<div class="bg-red-900/20 border border-red-700 rounded-lg p-4 mb-4">
<p class="text-red-400">{errorMessage}</p>
</div>
{/if}
{#if loadingMessage || completed > 0}
<Loader> <Loader>
{loadingMessage} {#if completed > 0}
<P>{formatBytes(completed)} / {formatBytes(totalSize)}</P>
<Progressbar {progress} />
{:else}
{loadingMessage}
{/if}
</Loader> </Loader>
{:else if zips} {:else if accessToken}
<div class="flex flex-col gap-2"> <div class="flex justify-between items-center gap-6 flex-col">
<div class="flex justify-between items-center gap-2 flex-col sm:flex-row"> <h2 class="text-3xl font-semibold text-center pt-2">Google Drive:</h2>
<h2 class="text-2xl font-semibold text-center">Google Drive:</h2> <p class="text-center">
<div class="flex flex-col sm:flex-row gap-2 sm:w-auto w-full"> Add your zipped manga files to the <span class="text-primary-700">{READER_FOLDER}</span> folder
in your Google Drive.
</p>
<div class="flex flex-col gap-4 w-full max-w-3xl">
<Button color="blue" on:click={createPicker}>Download manga</Button>
<div class="flex-col gap-2 flex">
<Button <Button
color="dark" color="dark"
on:click={() => promptConfirmation('Upload volume data?', onUploadVolumeData)} on:click={() => promptConfirmation('Upload volume data?', onUploadVolumeData)}
@@ -273,6 +379,8 @@
Download volume data Download volume data
</Button> </Button>
{/if} {/if}
</div>
<div class="flex-col gap-2 flex">
<Button <Button
color="dark" color="dark"
on:click={() => promptConfirmation('Upload profiles?', onUploadProfiles)} on:click={() => promptConfirmation('Upload profiles?', onUploadProfiles)}
@@ -290,42 +398,22 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex gap-2 justify-center flex-wrap">
{#if zips.length > 0}
<Listgroup active class="w-full">
{#each zips as zip}
<Frame
on:click={() => onClick(zip)}
rounded
border
class="divide-y divide-gray-200 dark:divide-gray-600"
>
<ListgroupItem normalClass="py-4">
<div class="flex flex-col gap-2">
<h2 class="font-semibold">{zip.name}</h2>
<p>{formatBytes(parseInt(zip.size || '0'))}</p>
</div>
</ListgroupItem>
</Frame>
{/each}
</Listgroup>
{:else}
<p class="text-center">
Add your zip files to the <span class="text-primary-700">{READER_FOLDER}</span> folder in
your Google Drive.
</p>
{/if}
</div>
</div> </div>
{:else} {:else}
<button <div class="flex justify-center pt-0 sm:pt-32">
class="w-full border rounded-lg border-slate-600 p-10 border-opacity-50 hover:bg-slate-800" <button
on:click={signIn} class="w-full border rounded-lg border-slate-600 p-10 border-opacity-50 hover:bg-slate-800 max-w-3xl"
> on:click={signIn}
<div class="flex sm:flex-row flex-col gap-2 items-center justify-center"> disabled={!gapiLoaded || !googleLoaded}
<GoogleSolid size="lg" /> >
<h2 class="text-lg">Connect to Google Drive</h2> <div class="flex sm:flex-row flex-col gap-2 items-center justify-center">
</div> <GoogleSolid size="lg" />
</button> <h2 class="text-lg">Connect to Google Drive</h2>
</div>
</button>
{#if !gapiLoaded || !googleLoaded}
<p class="text-sm text-gray-400 mt-2">Loading Google APIs...</p>
{/if}
</div>
{/if} {/if}
</div> </div>

View File

@@ -6,7 +6,7 @@
import { promptConfirmation, showSnackbar } from '$lib/util'; import { promptConfirmation, showSnackbar } from '$lib/util';
import { P, Progressbar } from 'flowbite-svelte'; import { P, Progressbar } from 'flowbite-svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
export const BASE_URL = 'https://www.mokuro.moe/manga'; export const BASE_URL = $page.url.searchParams.get('source') || 'https://mokuro.moe/manga';
const manga = $page.url.searchParams.get('manga'); const manga = $page.url.searchParams.get('manga');
const volume = $page.url.searchParams.get('volume'); const volume = $page.url.searchParams.get('volume');

View File

@@ -53,7 +53,7 @@
], ],
"start_url": "/", "start_url": "/",
"background_color": "#030712", "background_color": "#030712",
"display": "standalone", "display": "fullscreen",
"scope": "/", "scope": "/",
"theme_color": "#030712", "theme_color": "#030712",
"description": "Reader for Mokuro processed manga" "description": "Reader for Mokuro processed manga"

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */