mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[BETA] Initial Commit (Nix Support Only)
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
const SUWAYOMI = "http://127.0.0.1:4567";
|
||||
const GQL = `${SUWAYOMI}/api/graphql`;
|
||||
|
||||
export function thumbUrl(path: string): string {
|
||||
return `${SUWAYOMI}${path}`;
|
||||
}
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T;
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
// Retry with exponential backoff — Suwayomi may not be ready on first load
|
||||
async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delayMs = 500): Promise<Response> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(url, init);
|
||||
return res;
|
||||
} catch (e) {
|
||||
if (i === retries - 1) throw e;
|
||||
await new Promise((r) => setTimeout(r, delayMs * Math.pow(1.5, i)));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
export async function gql<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(GQL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
|
||||
if (json.errors?.length) {
|
||||
throw new Error(json.errors[0].message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface Keybinds {
|
||||
pageRight: string;
|
||||
pageLeft: string;
|
||||
firstPage: string;
|
||||
lastPage: string;
|
||||
chapterRight: string;
|
||||
chapterLeft: string;
|
||||
exitReader: string;
|
||||
close: string;
|
||||
toggleReadingDirection: string;
|
||||
togglePageStyle: string;
|
||||
toggleOffsetDoubleSpreads: string;
|
||||
toggleFullscreen: string;
|
||||
openSettings: string;
|
||||
toggleSidebar: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
pageRight: "ArrowRight",
|
||||
pageLeft: "ArrowLeft",
|
||||
firstPage: "ctrl+ArrowLeft",
|
||||
lastPage: "ctrl+ArrowRight",
|
||||
chapterRight: "]",
|
||||
chapterLeft: "[",
|
||||
exitReader: "Backspace",
|
||||
close: "Escape",
|
||||
toggleReadingDirection: "d",
|
||||
togglePageStyle: "q",
|
||||
toggleOffsetDoubleSpreads: "u",
|
||||
toggleFullscreen: "f",
|
||||
openSettings: "o",
|
||||
toggleSidebar: "s",
|
||||
};
|
||||
|
||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
pageRight: "Turn page right",
|
||||
pageLeft: "Turn page left",
|
||||
firstPage: "First page",
|
||||
lastPage: "Last page",
|
||||
chapterRight: "Change chapter right",
|
||||
chapterLeft: "Change chapter left",
|
||||
exitReader: "Exit reader",
|
||||
close: "Close",
|
||||
toggleReadingDirection: "Toggle reading direction",
|
||||
togglePageStyle: "Toggle page style",
|
||||
toggleOffsetDoubleSpreads: "Toggle double page offset",
|
||||
toggleFullscreen: "Toggle fullscreen",
|
||||
openSettings: "Show settings menu",
|
||||
toggleSidebar: "Toggle sidebar",
|
||||
};
|
||||
|
||||
export function eventToKeybind(e: KeyboardEvent): string {
|
||||
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey) parts.push("ctrl");
|
||||
if (e.altKey) parts.push("alt");
|
||||
if (e.shiftKey) parts.push("shift");
|
||||
if (e.metaKey) parts.push("meta");
|
||||
parts.push(e.key);
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
||||
return eventToKeybind(e) === bind;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// ── Library ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Full library query with chapter progress — only used for inLibrary manga
|
||||
export const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
inLibrary
|
||||
downloadCount
|
||||
unreadCount
|
||||
chapters {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Lightweight query for browse/search (no progress needed)
|
||||
export const GET_ALL_MANGA = `
|
||||
query GetAllManga {
|
||||
mangas {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
inLibrary
|
||||
downloadCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MANGA = `
|
||||
query GetManga($id: Int!) {
|
||||
manga(id: $id) {
|
||||
id
|
||||
title
|
||||
description
|
||||
thumbnailUrl
|
||||
status
|
||||
author
|
||||
artist
|
||||
genre
|
||||
inLibrary
|
||||
realUrl
|
||||
source {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_CHAPTERS = `
|
||||
query GetChapters($mangaId: Int!) {
|
||||
chapters(condition: { mangaId: $mangaId }) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
chapterNumber
|
||||
sourceOrder
|
||||
isRead
|
||||
isDownloaded
|
||||
isBookmarked
|
||||
pageCount
|
||||
mangaId
|
||||
uploadDate
|
||||
realUrl
|
||||
lastPageRead
|
||||
scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FETCH_CHAPTERS = `
|
||||
mutation FetchChapters($mangaId: Int!) {
|
||||
fetchChapters(input: { mangaId: $mangaId }) {
|
||||
chapters {
|
||||
id
|
||||
name
|
||||
chapterNumber
|
||||
sourceOrder
|
||||
isRead
|
||||
isDownloaded
|
||||
isBookmarked
|
||||
pageCount
|
||||
mangaId
|
||||
uploadDate
|
||||
realUrl
|
||||
lastPageRead
|
||||
scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FETCH_CHAPTER_PAGES = `
|
||||
mutation FetchChapterPages($chapterId: Int!) {
|
||||
fetchChapterPages(input: { chapterId: $chapterId }) {
|
||||
pages
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_MANGA = `
|
||||
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
||||
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
||||
manga {
|
||||
id
|
||||
inLibrary
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARK_CHAPTER_READ = `
|
||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||
chapter {
|
||||
id
|
||||
isRead
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MARK_CHAPTERS_READ = `
|
||||
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
||||
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
||||
chapters {
|
||||
id
|
||||
isRead
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||
mutation DeleteDownloadedChapters($ids: [Int!]!) {
|
||||
deleteDownloadedChapters(input: { ids: $ids }) {
|
||||
chapters {
|
||||
id
|
||||
isDownloaded
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Downloads ─────────────────────────────────────────────────────────────────
|
||||
// Updated to include manga title, thumbnail, and pageCount
|
||||
|
||||
export const GET_DOWNLOAD_STATUS = `
|
||||
query GetDownloadStatus {
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress
|
||||
state
|
||||
chapter {
|
||||
id
|
||||
name
|
||||
pageCount
|
||||
mangaId
|
||||
manga {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ENQUEUE_DOWNLOAD = `
|
||||
mutation EnqueueDownload($chapterId: Int!) {
|
||||
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress
|
||||
state
|
||||
chapter {
|
||||
id
|
||||
name
|
||||
pageCount
|
||||
mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
|
||||
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
|
||||
enqueueChapterDownloads(input: { ids: $chapterIds }) {
|
||||
downloadStatus {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DEQUEUE_DOWNLOAD = `
|
||||
mutation DequeueDownload($chapterId: Int!) {
|
||||
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const START_DOWNLOADER = `
|
||||
mutation StartDownloader {
|
||||
startDownloader {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const STOP_DOWNLOADER = `
|
||||
mutation StopDownloader {
|
||||
stopDownloader {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CLEAR_DOWNLOADER = `
|
||||
mutation ClearDownloader {
|
||||
clearDownloader {
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress
|
||||
state
|
||||
chapter {
|
||||
id name pageCount mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Sources ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET_SOURCES = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
lang
|
||||
displayName
|
||||
iconUrl
|
||||
isNsfw
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
|
||||
mangas {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
inLibrary
|
||||
}
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Extensions ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET_EXTENSIONS = `
|
||||
query GetExtensions {
|
||||
extensions {
|
||||
nodes {
|
||||
apkName
|
||||
pkgName
|
||||
name
|
||||
lang
|
||||
versionName
|
||||
isInstalled
|
||||
isObsolete
|
||||
hasUpdate
|
||||
iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FETCH_EXTENSIONS = `
|
||||
mutation FetchExtensions {
|
||||
fetchExtensions(input: {}) {
|
||||
extensions {
|
||||
apkName
|
||||
pkgName
|
||||
name
|
||||
lang
|
||||
versionName
|
||||
isInstalled
|
||||
isObsolete
|
||||
hasUpdate
|
||||
iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_EXTENSION = `
|
||||
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||
extension {
|
||||
apkName
|
||||
pkgName
|
||||
name
|
||||
isInstalled
|
||||
hasUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const INSTALL_EXTERNAL_EXTENSION = `
|
||||
mutation InstallExternalExtension($url: String!) {
|
||||
installExternalExtension(input: { extensionUrl: $url }) {
|
||||
extension {
|
||||
apkName
|
||||
pkgName
|
||||
name
|
||||
isInstalled
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,89 @@
|
||||
export interface Manga {
|
||||
id: number;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
inLibrary: boolean;
|
||||
downloadCount?: number;
|
||||
unreadCount?: number;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
author?: string | null;
|
||||
artist?: string | null;
|
||||
genre?: string[];
|
||||
realUrl?: string | null;
|
||||
source?: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: number;
|
||||
name: string;
|
||||
chapterNumber: number;
|
||||
sourceOrder: number;
|
||||
isRead: boolean;
|
||||
isDownloaded: boolean;
|
||||
isBookmarked: boolean;
|
||||
pageCount: number;
|
||||
mangaId: number;
|
||||
uploadDate?: string | null;
|
||||
realUrl?: string | null;
|
||||
lastPageRead?: number;
|
||||
scanlator?: string | null;
|
||||
}
|
||||
|
||||
export interface MangaDetail extends Manga {
|
||||
description: string | null;
|
||||
author: string | null;
|
||||
artist: string | null;
|
||||
status: string | null;
|
||||
genre: string[];
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
isNsfw: boolean;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
apkName: string;
|
||||
pkgName: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
versionName: string;
|
||||
isInstalled: boolean;
|
||||
isObsolete: boolean;
|
||||
hasUpdate: boolean;
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
export interface DownloadQueueItem {
|
||||
progress: number;
|
||||
state: "QUEUED" | "DOWNLOADING" | "FINISHED" | "ERROR";
|
||||
chapter: {
|
||||
id: number;
|
||||
name: string;
|
||||
mangaId: number;
|
||||
pageCount: number;
|
||||
manga: {
|
||||
id: number;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
state: "STARTED" | "STOPPED";
|
||||
queue: DownloadQueueItem[];
|
||||
}
|
||||
|
||||
export interface Connection<T> {
|
||||
nodes: T[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
Reference in New Issue
Block a user