[BETA] Initial Commit (Nix Support Only)

This commit is contained in:
Youwes09
2026-02-20 23:34:10 -06:00
commit 09554c68df
113 changed files with 14400 additions and 0 deletions
+48
View File
@@ -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;
}
+65
View File
@@ -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;
}
+350
View File
@@ -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
}
}
}
`;
+89
View File
@@ -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[];
}
+5
View File
@@ -0,0 +1,5 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}