Compare commits

..

6 Commits

Author SHA1 Message Date
Flatlogic Bot
1424e9761c Auto commit: 2026-02-05T21:00:01.068Z 2026-02-05 21:00:01 +00:00
Flatlogic Bot
4a312f7921 dave 2026-02-05 20:27:35 +00:00
Flatlogic Bot
8d558b2070 Dave 2026-02-05 04:03:52 +00:00
Flatlogic Bot
d5087fc4e7 dave 2026-02-05 04:02:20 +00:00
Flatlogic Bot
ef0c922762 Dave 2026-02-05 03:59:10 +00:00
Flatlogic Bot
c0c1673e46 dave 2026-02-05 03:55:29 +00:00
38 changed files with 1536 additions and 264 deletions

View File

@ -129,8 +129,8 @@
<p class="tip">The application is currently launching. The page will automatically refresh once site is
available.</p>
<div class="project-info">
<h2>App Draft</h2>
<p>Komikku-like web manga reader with library management, downloads, trackers sync, themes, and advanced reading modes.</p>
<h2>manhwa Kai</h2>
<p>manhwa Kai - Your ultimate web reader for manga, manhwa, and manhua.</p>
</div>
<div class="loader-container">
<img src="https://flatlogic.com/blog/wp-content/uploads/2025/05/logo-bot-1.png" alt="App Logo"
@ -184,4 +184,4 @@
</script>
</body>
</html>
</html>

View File

@ -1,6 +1,6 @@
# App Draft
# manhwa Kai
## This project was generated by [Flatlogic Platform](https://flatlogic.com).

View File

@ -1,5 +1,5 @@
#App Draft - template backend,
#manhwa Kai - template backend,
#### Run App on local machine:

View File

@ -1,6 +1,6 @@
{
"name": "appdraft",
"description": "App Draft - template backend",
"description": "manhwa Kai - template backend",
"scripts": {
"start": "npm run db:migrate && npm run db:seed && npm run watch",
"lint": "eslint . --ext .js",

View File

@ -1,6 +1,3 @@
const os = require('os');
const config = {
@ -39,7 +36,7 @@ const config = {
},
uploadDir: os.tmpdir(),
email: {
from: 'App Draft <app@flatlogic.app>',
from: 'manhwa Kai <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
@ -68,6 +65,7 @@ const config = {
};
config.pexelsKey = process.env.PEXELS_KEY || '';
config.jamendoClientId = process.env.JAMENDO_CLIENT_ID || '56d30cce'; // Example/Placeholder
config.pexelsQuery = 'City skyline at night';
config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost";

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -21,6 +20,8 @@ module.exports = class DownloadsDBApi {
{
id: data.id || undefined,
title: data.title || null,
download_type: data.download_type
||
null
@ -108,7 +109,7 @@ module.exports = class DownloadsDBApi {
// Prepare data - wrapping individual data transformations in a map() method
const downloadsData = data.map((item, index) => ({
id: item.id || undefined,
title: item.title || null,
download_type: item.download_type
||
null
@ -187,6 +188,8 @@ module.exports = class DownloadsDBApi {
const updatePayload = {};
if (data.title !== undefined) updatePayload.title = data.title;
if (data.download_type !== undefined) updatePayload.download_type = data.download_type;
@ -449,7 +452,17 @@ module.exports = class DownloadsDBApi {
};
}
if (filter.title) {
where = {
...where,
[Op.and]: Utils.ilike(
'downloads',
'title',
filter.title,
),
};
}
if (filter.storage_path) {
where = {
...where,
@ -716,6 +729,11 @@ module.exports = class DownloadsDBApi {
where = {
[Op.or]: [
{ ['id']: Utils.uuid(query) },
Utils.ilike(
'downloads',
'title',
query,
),
Utils.ilike(
'downloads',
'status',
@ -726,19 +744,18 @@ module.exports = class DownloadsDBApi {
}
const records = await db.downloads.findAll({
attributes: [ 'id', 'status' ],
attributes: [ 'id', 'title' ],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['status', 'ASC']],
orderBy: [['title', 'ASC']],
});
return records.map((record) => ({
id: record.id,
label: record.status,
label: record.title || record.id,
}));
}
};
};

View File

@ -0,0 +1,14 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('downloads', 'title', {
type: Sequelize.STRING,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('downloads', 'title');
}
};

View File

@ -14,6 +14,10 @@ module.exports = function(sequelize, DataTypes) {
primaryKey: true,
},
title: {
type: DataTypes.STRING,
},
download_type: {
type: DataTypes.ENUM,
@ -24,7 +28,11 @@ download_type: {
"chapter",
"series"
"series",
"music",
"video"
],
@ -204,5 +212,3 @@ finished_at: {
return downloads;
};

View File

@ -81,7 +81,7 @@ const SourcesData = [
"name": "Komikku Demo Catalog",
"name": "manhwa Kai Demo Catalog",
@ -343,6 +343,17 @@ const SourcesData = [
},
{
"name": "Keiyoushi Extensions",
"base_url": "https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json",
"source_type": "custom_repo",
"enabled": true,
"supports_nsfw": true,
"default_language": "en",
"region": "global",
"rate_limit_per_minute": 100,
"last_healthcheck_at": new Date('2026-02-05T08:40:00Z')
},
];
@ -673,6 +684,30 @@ const ExtensionsData = [
},
{
"name": "MangaDex",
"package_name": "eu.kanade.tachiyomi.extension.all.mangadex",
"version": "1.4.206",
"website_url": "https://mangadex.org",
"repo_url": "https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json",
"install_status": "available",
"nsfw_capable": true,
"signature_verified": true,
"installed_at": new Date('2026-02-05T08:50:00Z'),
"last_updated_at": new Date('2026-02-05T08:50:00Z')
},
{
"name": "Comick",
"package_name": "eu.kanade.tachiyomi.extension.all.comicklive",
"version": "1.4.3",
"website_url": "https://comick.live",
"repo_url": "https://raw.githubusercontent.com/keiyoushi/extensions/repo/index.min.json",
"install_status": "available",
"nsfw_capable": true,
"signature_verified": true,
"installed_at": new Date('2026-02-05T08:55:00Z'),
"last_updated_at": new Date('2026-02-05T08:55:00Z')
},
];

View File

@ -1,4 +1,3 @@
const express = require('express');
const cors = require('cors');
const app = express();
@ -16,9 +15,10 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const mediaRoutes = require('./routes/media');
const openaiRoutes = require('./routes/openai');
const mangaRoutes = require('./routes/manga');
const usersRoutes = require('./routes/users');
@ -78,8 +78,8 @@ const options = {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "App Draft",
description: "App Draft Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.",
title: "manhwa Kai",
description: "manhwa Kai REST API for manga reading platform. You can perform all major operations with your entities - create, delete and etc.",
},
servers: [
{
@ -101,29 +101,35 @@ const options = {
}
}
},
security: [{
bearerAuth: []
}]
},
apis: ["./src/routes/*.js"],
};
const specs = swaggerJsDoc(options);
app.use('/api-docs', function (req, res, next) {
swaggerUI.host = getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || req.get('host');
next()
}, swaggerUI.serve, swaggerUI.setup(specs))
app.use(cors({origin: true}));
require('./auth/auth');
app.use(cors());
app.use(bodyParser.json());
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs));
require('./auth/auth');
app.get('/api', (req, res) => {
res.send('Hello from manhwa Kai API!');
});
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.enable('trust proxy');
app.use('/api/media', passport.authenticate('jwt', {session: false}), mediaRoutes);
app.use('/api/openai', passport.authenticate('jwt', {session: false}), openaiRoutes);
app.use('/api/manga', passport.authenticate('jwt', {session: false}), mangaRoutes);
app.use('/api/search', passport.authenticate('jwt', {session: false}), searchRoutes);
app.use('/api/sql', passport.authenticate('jwt', {session: false}), sqlRoutes);
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);
@ -171,48 +177,10 @@ app.use('/api/search_history', passport.authenticate('jwt', {session: false}), s
app.use('/api/reading_progress', passport.authenticate('jwt', {session: false}), reading_progressRoutes);
app.use(
'/api/openai',
passport.authenticate('jwt', { session: false }),
openaiRoutes,
);
app.use(
'/api/ai',
passport.authenticate('jwt', { session: false }),
openaiRoutes,
);
app.use(
'/api/search',
passport.authenticate('jwt', { session: false }),
searchRoutes);
app.use(
'/api/sql',
passport.authenticate('jwt', { session: false }),
sqlRoutes);
const publicDir = path.join(
__dirname,
'../public',
);
app.use('/api/file/download', express.static(config.uploadDir));
if (fs.existsSync(publicDir)) {
app.use('/', express.static(publicDir));
app.get('*', function(request, response) {
response.sendFile(
path.resolve(publicDir, 'index.html'),
);
});
}
const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
db.sequelize.sync().then(function () {
app.listen(PORT, () => {
console.log(`Listening on port ${PORT}`);
});
app.listen(config.port || 8080, () => {
console.log(`manhwa Kai backend listening at http://localhost:${config.port || 8080}`);
});
module.exports = app;

View File

@ -0,0 +1,30 @@
const express = require('express');
const router = express.Router();
const MangaScraperService = require('../services/mangaScraper');
const { wrapAsync } = require('../helpers');
router.get('/search', wrapAsync(async (req, res) => {
const { query, sourceId } = req.query;
const results = await MangaScraperService.search(query, sourceId);
res.json(results);
}));
router.get('/details/:sourceId/:mangaId', wrapAsync(async (req, res) => {
const { sourceId, mangaId } = req.params;
const details = await MangaScraperService.getMangaDetails(mangaId, sourceId);
res.json(details);
}));
router.get('/chapters/:sourceId/:mangaId', wrapAsync(async (req, res) => {
const { sourceId, mangaId } = req.params;
const chapters = await MangaScraperService.getChapters(mangaId, sourceId);
res.json(chapters);
}));
router.get('/pages/:sourceId/:chapterId', wrapAsync(async (req, res) => {
const { sourceId, chapterId } = req.params;
const pages = await MangaScraperService.getPages(chapterId, sourceId);
res.json(pages);
}));
module.exports = router;

View File

@ -0,0 +1,23 @@
const express = require('express');
const router = express.Router();
const MediaService = require('../services/media');
const { wrapAsync } = require('../helpers');
router.get('/search/music', wrapAsync(async (req, res) => {
const { query } = req.query;
const results = await MediaService.searchMusic(query);
res.status(200).json(results);
}));
router.get('/search/video', wrapAsync(async (req, res) => {
const { query } = req.query;
const results = await MediaService.searchVideo(query);
res.status(200).json(results);
}));
router.post('/download', wrapAsync(async (req, res) => {
const result = await MediaService.startDownload(req.body, req.currentUser);
res.status(200).json(result);
}));
module.exports = router;

View File

@ -0,0 +1,168 @@
const axios = require('axios');
const db = require('../db/models');
class MangaScraperService {
static async getSource(sourceId) {
const source = await db.sources.findByPk(sourceId);
if (!source) throw new Error('Source not found');
return source;
}
static async search(query, sourceId) {
const source = await this.getSource(sourceId);
// For now, let's implement a real MangaDex search if the name matches,
// otherwise fallback to a mock search.
if (source.name.toLowerCase().includes('mangadex') || source.base_url.includes('mangadex.org')) {
return this.searchMangaDex(query);
}
return this.mockSearch(query, source);
}
static async searchMangaDex(query) {
const response = await axios.get('https://api.mangadex.org/manga', {
params: {
title: query,
limit: 20,
'includes[]': ['cover_art']
}
});
return response.data.data.map(manga => {
const coverArt = manga.relationships.find(r => r.type === 'cover_art');
const fileName = coverArt ? coverArt.attributes?.fileName : null;
const coverUrl = fileName ? `https://uploads.mangadex.org/covers/${manga.id}/${fileName}.256.jpg` : null;
return {
id: manga.id,
title: manga.attributes.title.en || Object.values(manga.attributes.title)[0],
description: manga.attributes.description.en,
coverUrl: coverUrl,
source: 'MangaDex'
};
});
}
static async mockSearch(query, source) {
// Return some mock data based on the source
return [
{
id: `mock-${source.id}-1`,
title: `${query} in ${source.name}`,
description: `This is a mock result for ${query} from ${source.name}`,
coverUrl: 'https://via.placeholder.com/256x360?text=Manga+Cover',
source: source.name
}
];
}
static async getMangaDetails(mangaId, sourceId) {
const source = await this.getSource(sourceId);
if (source.name.toLowerCase().includes('mangadex') || source.base_url.includes('mangadex.org')) {
return this.getMangaDexDetails(mangaId);
}
return this.mockMangaDetails(mangaId, source);
}
static async getMangaDexDetails(mangaId) {
const response = await axios.get(`https://api.mangadex.org/manga/${mangaId}`, {
params: {
'includes[]': ['cover_art', 'author', 'artist']
}
});
const manga = response.data.data;
const coverArt = manga.relationships.find(r => r.type === 'cover_art');
const fileName = coverArt ? coverArt.attributes?.fileName : null;
const coverUrl = fileName ? `https://uploads.mangadex.org/covers/${manga.id}/${fileName}.512.jpg` : null;
return {
id: manga.id,
title: manga.attributes.title.en || Object.values(manga.attributes.title)[0],
description: manga.attributes.description.en,
coverUrl: coverUrl,
author: manga.relationships.find(r => r.type === 'author')?.attributes?.name,
artist: manga.relationships.find(r => r.type === 'artist')?.attributes?.name,
status: manga.attributes.status,
year: manga.attributes.year,
source: 'MangaDex'
};
}
static async getChapters(mangaId, sourceId) {
const source = await this.getSource(sourceId);
if (source.name.toLowerCase().includes('mangadex') || source.base_url.includes('mangadex.org')) {
return this.getMangaDexChapters(mangaId);
}
return this.mockChapters(mangaId, source);
}
static async getMangaDexChapters(mangaId) {
const response = await axios.get(`https://api.mangadex.org/manga/${mangaId}/feed`, {
params: {
translatedLanguage: ['en'],
order: { chapter: 'desc' },
limit: 100
}
});
return response.data.data.map(chapter => ({
id: chapter.id,
chapter: chapter.attributes.chapter,
title: chapter.attributes.title,
language: chapter.attributes.translatedLanguage,
externalUrl: chapter.attributes.externalUrl
}));
}
static async getPages(chapterId, sourceId) {
const source = await this.getSource(sourceId);
if (source.name.toLowerCase().includes('mangadex') || source.base_url.includes('mangadex.org')) {
return this.getMangaDexPages(chapterId);
}
return this.mockPages(chapterId, source);
}
static async getMangaDexPages(chapterId) {
const response = await axios.get(`https://api.mangadex.org/at-home/server/${chapterId}`);
const { baseUrl, chapter } = response.data;
const hash = chapter.hash;
const files = chapter.data;
return files.map(file => `${baseUrl}/data/${hash}/${file}`);
}
static async mockMangaDetails(mangaId, source) {
return {
id: mangaId,
title: `Mock Manga ${mangaId}`,
description: `Description for mock manga ${mangaId} from ${source.name}`,
coverUrl: 'https://via.placeholder.com/512x720?text=Manga+Cover',
source: source.name
};
}
static async mockChapters(mangaId, source) {
return [
{ id: 'chap-1', chapter: '1', title: 'Beginning' },
{ id: 'chap-2', chapter: '2', title: 'The Journey' }
];
}
static async mockPages(chapterId, source) {
return [
'https://via.placeholder.com/800x1200?text=Page+1',
'https://via.placeholder.com/800x1200?text=Page+2',
'https://via.placeholder.com/800x1200?text=Page+3'
];
}
}
module.exports = MangaScraperService;

View File

@ -0,0 +1,152 @@
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const config = require('../config');
const db = require('../db/models');
const DownloadsDBApi = require('../db/api/downloads');
module.exports = class MediaService {
static async searchMusic(query) {
const url = `https://api.jamendo.com/v3.0/tracks/?client_id=${config.jamendoClientId}&format=json&limit=10&search=${encodeURIComponent(query)}&include=musicinfo&audioformat=mp32`;
try {
const response = await axios.get(url);
return response.data.results.map(track => ({
id: track.id,
title: track.name,
artist: track.artist_name,
album: track.album_name,
duration: track.duration,
url: track.audio,
image: track.image,
type: 'music'
}));
} catch (error) {
console.error('Jamendo search error:', error);
return [];
}
}
static async searchVideo(query) {
const url = `https://api.pexels.com/videos/search?query=${encodeURIComponent(query)}&per_page=10`;
try {
const response = await axios.get(url, {
headers: {
Authorization: config.pexelsKey
}
});
return response.data.videos.map(video => ({
id: video.id,
title: `Video by ${video.user.name}`,
artist: video.user.name,
url: video.video_files.find(f => f.file_type === 'video/mp4')?.link || video.video_files[0].link,
image: video.image,
duration: video.duration,
type: 'video'
}));
} catch (error) {
console.error('Pexels search error:', error);
return [];
}
}
static async startDownload(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const downloadData = {
title: data.title,
download_type: data.type, // 'music' or 'video'
status: 'queued',
progress_percent: 0,
storage_path: data.url,
userId: currentUser.id,
queued_at: new Date(),
};
const download = await DownloadsDBApi.create(downloadData, {
currentUser,
transaction,
});
await transaction.commit();
// Start the real download process asynchronously
this.realDownload(download.id, data.url, data.type, currentUser);
return download;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async realDownload(id, url, type, currentUser) {
try {
const fileName = `${id}.${type === 'music' ? 'mp3' : 'mp4'}`;
const filePath = path.join(config.uploadDir, fileName);
const response = await axios({
method: 'GET',
url: url,
responseType: 'stream'
});
const totalLength = response.headers['content-length'];
let downloadedLength = 0;
const writer = fs.createWriteStream(filePath);
response.data.on('data', (chunk) => {
downloadedLength += chunk.length;
const progress = totalLength ? Math.round((downloadedLength / totalLength) * 100) : 0;
// Throttle updates to DB to avoid overloading
if (!totalLength || downloadedLength % (1024 * 1024) < chunk.length || progress === 100) {
DownloadsDBApi.update(id, {
status: 'downloading',
progress_percent: progress,
downloaded_bytes: downloadedLength,
total_bytes: totalLength || 0,
started_at: downloadedLength === chunk.length ? new Date() : undefined
}, { currentUser }).catch(err => console.error('Update download progress error:', err));
}
});
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', async () => {
try {
await DownloadsDBApi.update(id, {
status: 'completed',
progress_percent: 100,
storage_path: fileName,
finished_at: new Date()
}, { currentUser });
resolve();
} catch (err) {
reject(err);
}
});
writer.on('error', async (err) => {
try {
await DownloadsDBApi.update(id, {
status: 'failed',
}, { currentUser });
} catch (dbErr) {
console.error('Failed to update download status to failed:', dbErr);
}
reject(err);
});
});
} catch (error) {
console.error('Download error:', error);
try {
await DownloadsDBApi.update(id, {
status: 'failed',
}, { currentUser });
} catch (dbErr) {
console.error('Failed to update download status to failed after catch:', dbErr);
}
}
}
};

View File

@ -1,6 +1,6 @@
const errors = {
app: {
title: 'App Draft',
title: 'manhwa Kai',
},
auth: {
@ -101,4 +101,4 @@ const errors = {
},
};
module.exports = errors;
module.exports = errors;

View File

@ -1,4 +1,4 @@
# App Draft
# manhwa Kai
## This project was generated by Flatlogic Platform.
## Install

View File

@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">App Draft</b>
<b className="font-black">manhwa Kai</b>
</div>
@ -60,4 +60,4 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
</div>
</aside>
)
}
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import { mdiEye, mdiTrashCan, mdiPencilOutline, mdiPlay } from '@mdi/js';
import axios from 'axios';
import {
GridActionsCellItem,
@ -12,6 +12,7 @@ import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover';
import { IconButton, Tooltip } from '@mui/material';
import {hasPermission} from "../../helpers/userPermissions";
@ -40,7 +41,16 @@ export const loadColumns = async (
const hasUpdatePermission = hasPermission(user, 'UPDATE_DOWNLOADS')
return [
{
field: 'title',
headerName: 'Title',
flex: 1,
minWidth: 150,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'download_type',
headerName: 'DownloadType',
@ -273,13 +283,28 @@ export const loadColumns = async (
{
field: 'actions',
type: 'actions',
minWidth: 30,
minWidth: 80,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
const isMedia = ['music', 'video'].includes(params.row.download_type);
const isCompleted = params.row.status === 'completed';
return [
<div key={params?.row?.id}>
<div key={params?.row?.id} style={{ display: 'flex', alignItems: 'center' }}>
{isMedia && isCompleted && (
<Tooltip title="Play">
<IconButton
onClick={() => {
const url = `${axios.defaults.baseURL}/file/download?privateUrl=${params.row.storage_path}`;
window.open(url, '_blank');
}}
size="small"
>
<BaseIcon path={mdiPlay} size={20} />
</IconButton>
</Tooltip>
)}
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}

View File

@ -8,8 +8,8 @@ export const localStorageStyleKey = 'style'
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
export const appTitle = 'created by Flatlogic generator!'
export const appTitle = 'manhwa Kai'
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle}${appTitle}`
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''
export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || ''

View File

@ -7,7 +7,32 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/browse-sources',
icon: icon.mdiSourceBranch,
label: 'Browse Extensions',
},
{
href: '/media',
icon: icon.mdiPlayCircle,
label: 'Media Search',
},
{
href: '/library_entries/library_entries-list',
label: 'My Library',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLibraryShelves' in icon ? icon['mdiLibraryShelves' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LIBRARY_ENTRIES'
},
{
href: '/series/series-list',
label: 'Local Series',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBookOpenPageVariant' in icon ? icon['mdiBookOpenPageVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SERIES'
},
{
href: '/users/users-list',
label: 'Users',
@ -34,7 +59,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/sources/sources-list',
label: 'Sources',
label: 'Sources Admin',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiSourceRepository' in icon ? icon['mdiSourceRepository' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -42,20 +67,12 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/extensions/extensions-list',
label: 'Extensions',
label: 'Extensions Admin',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiPuzzle' in icon ? icon['mdiPuzzle' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EXTENSIONS'
},
{
href: '/series/series-list',
label: 'Series',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBookOpenPageVariant' in icon ? icon['mdiBookOpenPageVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SERIES'
},
{
href: '/chapters/chapters-list',
label: 'Chapters',
@ -72,14 +89,6 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CHAPTER_PAGES'
},
{
href: '/library_entries/library_entries-list',
label: 'Library entries',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLibraryShelves' in icon ? icon['mdiLibraryShelves' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_LIBRARY_ENTRIES'
},
{
href: '/categories/categories-list',
label: 'Categories',

View File

@ -149,8 +149,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setStepsEnabled(false);
};
const title = 'App Draft'
const description = "Komikku-like web manga reader with library management, downloads, trackers sync, themes, and advanced reading modes."
const title = 'manhwa Kai'
const description = "manhwa Kai - Your ultimate web reader for manga, manhwa, and manhua."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/38203/app-hero-20260205-034702.png"
const imageWidth = '1920'
@ -164,7 +164,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<meta name="description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:site_name" content="https://flatlogic.com/" />
<meta property="og:site_name" content="manhwa Kai" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
@ -198,4 +198,4 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
)
}
export default appWithTranslation(MyApp);
export default appWithTranslation(MyApp);

View File

@ -0,0 +1,83 @@
import React, { useEffect } from 'react'
import Head from 'next/head'
import { mdiOpenInApp, mdiSourceBranch, mdiRefresh } from '@mdi/js'
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import CardBox from '../components/CardBox'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import { fetch } from '../stores/sources/sourcesSlice'
import BaseButton from '../components/BaseButton'
import { getPageTitle } from '../config'
import Link from 'next/link'
const BrowseSourcesPage = () => {
const dispatch = useAppDispatch()
const { sources, loading } = useAppSelector((state) => state.sources)
useEffect(() => {
dispatch(fetch({}))
}, [dispatch])
return (
<>
<Head>
<title>{getPageTitle('Browse Sources')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiSourceBranch} title="Manga Sources" main>
<BaseButton
icon={mdiRefresh}
color="whiteDark"
onClick={() => dispatch(fetch({}))}
disabled={loading}
/>
</SectionTitleLineWithButton>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
{sources.map((source: any) => (
<CardBox key={source.id} className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold">{source.name}</h2>
<span className={`px-2 py-1 text-xs rounded ${source.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{source.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<p className="text-gray-500 text-sm mb-4 flex-grow">
{source.base_url}
</p>
<div className="flex justify-between items-center">
<span className="text-xs text-gray-400 uppercase tracking-wider">
{source.source_type} {source.default_language}
</span>
<Link href={`/sources/browse/${source.id}`} passHref legacyBehavior>
<BaseButton
color="info"
label="Browse"
icon={mdiOpenInApp}
small
/>
</Link>
</div>
</CardBox>
))}
</div>
{sources.length === 0 && !loading && (
<CardBox>
<div className="text-center py-10">
<p className="text-gray-500">No sources found. Add a source in the admin panel to get started.</p>
</div>
</CardBox>
)}
</SectionMain>
</>
)
}
BrowseSourcesPage.getLayout = function getLayout(page: React.ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default BrowseSourcesPage

View File

@ -477,6 +477,8 @@ const EditDownloads = () => {
<option value="chapter">chapter</option>
<option value="series">series</option>
<option value="music">music</option>
<option value="video">video</option>
</Field>
</FormField>

View File

@ -474,6 +474,8 @@ const EditDownloadsPage = () => {
<option value="chapter">chapter</option>
<option value="series">series</option>
<option value="music">music</option>
<option value="video">video</option>
</Field>
</FormField>

View File

@ -52,7 +52,7 @@ const DownloadsTablesPage = () => {
{label: 'DownloadType', title: 'download_type', type: 'enum', options: ['chapter','series']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','downloading','paused','completed','failed','cancelled']},
{label: 'DownloadType', title: 'download_type', type: 'enum', options: ['chapter','series','music','video']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','downloading','paused','completed','failed','cancelled']},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOWNLOADS');

View File

@ -295,6 +295,8 @@ const DownloadsNew = () => {
<option value="chapter">chapter</option>
<option value="series">series</option>
<option value="music">music</option>
<option value="video">video</option>
</Field>
</FormField>

View File

@ -34,7 +34,7 @@ const DownloadsTablesPage = () => {
const dispatch = useAppDispatch();
const [filters] = useState([{label: 'StoragePath', title: 'storage_path'},
const [filters] = useState([{label: 'Title', title: 'title'}, {label: 'StoragePath', title: 'storage_path'},
{label: 'ProgressPercent', title: 'progress_percent', number: 'true'},{label: 'TotalBytes', title: 'total_bytes', number: 'true'},{label: 'DownloadedBytes', title: 'downloaded_bytes', number: 'true'},
{label: 'QueuedAt', title: 'queued_at', date: 'true'},{label: 'StartedAt', title: 'started_at', date: 'true'},{label: 'FinishedAt', title: 'finished_at', date: 'true'},
@ -52,7 +52,7 @@ const DownloadsTablesPage = () => {
{label: 'DownloadType', title: 'download_type', type: 'enum', options: ['chapter','series']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','downloading','paused','completed','failed','cancelled']},
{label: 'DownloadType', title: 'download_type', type: 'enum', options: ['chapter','series','music','video']},{label: 'Status', title: 'status', type: 'enum', options: ['queued','downloading','paused','completed','failed','cancelled']},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOWNLOADS');
@ -173,4 +173,4 @@ DownloadsTablesPage.getLayout = function getLayout(page: ReactElement) {
)
}
export default DownloadsTablesPage
export default DownloadsTablesPage

View File

@ -1,166 +1,326 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
mdiMagnify,
mdiBookOpenPageVariant,
mdiSync,
mdiThemeLightDark,
mdiDownload,
mdiCellphoneMarker,
mdiShieldCheck,
mdiGithub,
} from '@mdi/js';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const FEATURED_MANGA = [
{
id: 1,
title: 'Shadow Chronicles',
image: 'https://images.unsplash.com/photo-1578632738980-43318b770d76?w=400&h=600&fit=crop',
rating: '4.9',
chapters: '124',
},
{
id: 2,
title: 'Neon Catalyst',
image: 'https://images.unsplash.com/photo-1607604276483-4efdd6d43bb3?w=400&h=600&fit=crop',
rating: '4.7',
chapters: '85',
},
{
id: 3,
title: 'Eternal Bloom',
image: 'https://images.unsplash.com/photo-1541562232579-512a21360020?w=400&h=600&fit=crop',
rating: '4.8',
chapters: '210',
},
{
id: 4,
title: 'Void Walker',
image: 'https://images.unsplash.com/photo-1560972550-aba3456b5564?w=400&h=600&fit=crop',
rating: '4.6',
chapters: '45',
},
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const FEATURES = [
{
title: 'Universal Reader',
description: 'Customize your reading experience with vertical, horizontal, or webtoon scrolling modes.',
icon: mdiBookOpenPageVariant,
color: 'text-indigo-500',
},
{
title: 'Tracker Sync',
description: 'Automatically sync your reading progress with MyAnimeList, AniList, and more.',
icon: mdiSync,
color: 'text-cyan-500',
},
{
title: 'Offline Access',
description: 'Download your favorite chapters and read them anywhere, even without an internet connection.',
icon: mdiDownload,
color: 'text-rose-500',
},
{
title: 'Custom Themes',
description: 'Choose from multiple themes or use our "Theme Based on Cover" for a unique look.',
icon: mdiThemeLightDark,
color: 'text-amber-500',
},
];
const title = 'App Draft'
export default function LandingPage() {
const router = useRouter();
const { currentUser } = useAppSelector((state) => state.auth);
const [searchQuery, setSearchQuery] = useState('');
const [isScrolled, setIsScrolled] = useState(false);
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 50);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
router.push(`/search?query=${encodeURIComponent(searchQuery)}`);
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans selection:bg-indigo-500 selection:text-white">
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Your Ultimate Manga Reader')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
{/* Navigation */}
<nav
className={`fixed top-0 w-full z-50 transition-all duration-300 ${
isScrolled ? 'bg-slate-900/80 backdrop-blur-md border-b border-slate-800 py-3' : 'bg-transparent py-6'
}`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your App Draft app!"/>
<div className="space-y-3">
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center '>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className="container mx-auto px-6 flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/20">
<BaseIcon path={mdiBookOpenPageVariant} className="text-white" size={24} />
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<span className="text-2xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-400">
manhwa Kai
</span>
</div>
</BaseButtons>
</CardBox>
<div className="hidden md:flex items-center space-x-8">
<a href="#features" className="text-sm font-medium text-slate-300 hover:text-white transition-colors">
Features
</a>
<a href="#popular" className="text-sm font-medium text-slate-300 hover:text-white transition-colors">
Popular
</a>
{currentUser ? (
<BaseButton href="/dashboard" label="Dashboard" color="info" className="rounded-full px-6" />
) : (
<div className="flex items-center space-x-4">
<Link href="/login" className="text-sm font-medium text-slate-300 hover:text-white transition-colors">
Login
</Link>
<BaseButton href="/register" label="Get Started" color="info" className="rounded-full px-6" />
</div>
)}
</div>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</nav>
{/* Hero Section */}
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full -z-10 pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo-600/20 blur-[120px] rounded-full" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-cyan-600/10 blur-[120px] rounded-full" />
</div>
<div className="container mx-auto px-6 text-center">
<h1 className="text-5xl lg:text-7xl font-extrabold mb-8 tracking-tight">
Discover and Read <br />
<span className="bg-clip-text text-transparent bg-gradient-to-r from-indigo-400 to-cyan-400">
Your Favorite Manga
</span>
</h1>
<p className="text-lg lg:text-xl text-slate-400 max-w-2xl mx-auto mb-12">
The most versatile open-source manga reader for the web. Track your series, customize your reader, and sync across devices.
</p>
<form onSubmit={handleSearch} className="max-w-2xl mx-auto relative group">
<div className="absolute inset-y-0 left-5 flex items-center pointer-events-none">
<BaseIcon path={mdiMagnify} className="text-slate-500 group-focus-within:text-indigo-400 transition-colors" size={24} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search across 100+ sources..."
className="w-full bg-slate-800/50 border border-slate-700 backdrop-blur-sm rounded-2xl py-5 pl-14 pr-32 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all text-white placeholder:text-slate-500 shadow-2xl"
/>
<button
type="submit"
className="absolute right-2 top-2 bottom-2 px-8 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-600/20 active:scale-95"
>
Search
</button>
</form>
<div className="mt-16 flex flex-wrap justify-center gap-4 text-sm text-slate-500 font-medium">
<div className="flex items-center space-x-2 bg-slate-800/40 px-4 py-2 rounded-full border border-slate-700/50">
<BaseIcon path={mdiShieldCheck} className="text-green-500" size={16} />
<span>Ad-Free</span>
</div>
<div className="flex items-center space-x-2 bg-slate-800/40 px-4 py-2 rounded-full border border-slate-700/50">
<BaseIcon path={mdiGithub} className="text-slate-400" size={16} />
<span>Open Source</span>
</div>
<div className="flex items-center space-x-2 bg-slate-800/40 px-4 py-2 rounded-full border border-slate-700/50">
<BaseIcon path={mdiCellphoneMarker} className="text-indigo-400" size={16} />
<span>Mobile Ready</span>
</div>
</div>
</div>
</section>
{/* Popular Section */}
<section id="popular" className="py-20 bg-slate-900/50">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between mb-12">
<div>
<h2 className="text-3xl font-bold mb-2">Popular Right Now</h2>
<p className="text-slate-400">Trending series across all extensions</p>
</div>
<Link href="/series/series-list" className="text-indigo-400 hover:text-indigo-300 font-medium flex items-center group">
View All
<span className="ml-2 group-hover:translate-x-1 transition-transform"></span>
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{FEATURED_MANGA.map((manga) => (
<div key={manga.id} className="group cursor-pointer">
<div className="relative aspect-[2/3] rounded-2xl overflow-hidden mb-4 shadow-xl ring-1 ring-slate-800">
<img
src={manga.image}
alt={manga.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-60" />
<div className="absolute top-3 left-3 bg-slate-900/80 backdrop-blur-md px-2 py-1 rounded-lg border border-slate-700/50 flex items-center space-x-1">
<span className="text-xs font-bold text-amber-400"></span>
<span className="text-xs font-bold">{manga.rating}</span>
</div>
<div className="absolute bottom-3 left-3 text-xs font-medium text-slate-300">
{manga.chapters} Chapters
</div>
</div>
<h3 className="font-bold text-lg group-hover:text-indigo-400 transition-colors line-clamp-1">
{manga.title}
</h3>
</div>
))}
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="py-20">
<div className="container mx-auto px-6 text-center mb-16">
<h2 className="text-3xl lg:text-5xl font-extrabold mb-4">Powerful Features</h2>
<p className="text-slate-400 max-w-2xl mx-auto text-lg">
Everything you need for the perfect reading experience, all in one place.
</p>
</div>
<div className="container mx-auto px-6 grid md:grid-cols-2 lg:grid-cols-4 gap-8">
{FEATURES.map((feature, idx) => (
<div
key={idx}
className="p-8 bg-slate-800/30 rounded-3xl border border-slate-700/50 hover:border-indigo-500/50 hover:bg-slate-800/50 transition-all group"
>
<div className={`w-14 h-14 rounded-2xl bg-slate-800 flex items-center justify-center mb-6 shadow-lg group-hover:scale-110 transition-transform`}>
<BaseIcon path={feature.icon} className={feature.color} size={32} />
</div>
<h3 className="text-xl font-bold mb-3">{feature.title}</h3>
<p className="text-slate-400 leading-relaxed text-sm">
{feature.description}
</p>
</div>
))}
</div>
</section>
{/* CTA Section */}
<section className="py-20 container mx-auto px-6">
<div className="relative rounded-[3rem] overflow-hidden bg-indigo-600 px-8 py-16 lg:px-20 lg:py-24 text-center">
<div className="absolute top-0 left-0 w-full h-full -z-1 pointer-events-none">
<div className="absolute top-[-50%] left-[-20%] w-[60%] h-[120%] bg-white/10 blur-[120px] rounded-full rotate-12" />
</div>
<h2 className="text-4xl lg:text-6xl font-extrabold text-white mb-8 tracking-tight">
Ready to Start Reading?
</h2>
<p className="text-indigo-100 text-lg lg:text-xl max-w-2xl mx-auto mb-12">
Join thousands of readers and experience manga like never before. Completely free and open-source.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<BaseButton
href="/register"
label="Create Free Account"
color="white"
className="w-full sm:w-auto px-10 py-4 text-indigo-600 font-bold rounded-2xl shadow-xl shadow-black/10"
/>
<Link
href="/login"
className="text-white font-bold hover:underline"
>
Already have an account? Log in
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-12 border-t border-slate-800">
<div className="container mx-auto px-6 flex flex-col md:flex-row items-center justify-between">
<div className="flex items-center space-x-2 mb-8 md:mb-0">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
<BaseIcon path={mdiBookOpenPageVariant} className="text-white" size={18} />
</div>
<span className="text-xl font-bold tracking-tight">manhwa Kai</span>
</div>
<div className="flex flex-wrap justify-center gap-8 text-sm text-slate-500 font-medium mb-8 md:mb-0">
<Link href="/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</Link>
<Link href="/terms-of-use" className="hover:text-white transition-colors">Terms of Use</Link>
<a href="#" className="hover:text-white transition-colors flex items-center">
<BaseIcon path={mdiGithub} size={16} className="mr-2" />
Source Code
</a>
</div>
<div className="text-sm text-slate-500">
© 2026 manhwa Kai. All rights reserved.
</div>
</div>
</footer>
</div>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
LandingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -1,5 +1,3 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
@ -44,7 +42,7 @@ export default function Login() {
password: '09f1e4eb',
remember: true })
const title = 'App Draft'
const title = 'manhwa Kai'
// Fetch Pexels image/video
useEffect( () => {
@ -273,4 +271,4 @@ export default function Login() {
Login.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -0,0 +1,124 @@
import React, { useEffect } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { mdiBookOpenVariant, mdiArrowLeft, mdiInformation } from '@mdi/js'
import LayoutAuthenticated from '../../../../layouts/Authenticated'
import SectionMain from '../../../../components/SectionMain'
import SectionTitleLineWithButton from '../../../../components/SectionTitleLineWithButton'
import CardBox from '../../../../components/CardBox'
import { useAppDispatch, useAppSelector } from '../../../../stores/hooks'
import { fetchMangaDetails, fetchChapters } from '../../../../stores/mangaSlice'
import BaseButton from '../../../../components/BaseButton'
import { getPageTitle } from '../../../../config'
import Link from 'next/link'
const MangaDetailsPage = () => {
const router = useRouter()
const { sourceId, mangaId } = router.query
const dispatch = useAppDispatch()
const { mangaDetails, chapters, loading } = useAppSelector((state) => state.manga)
useEffect(() => {
if (sourceId && mangaId && typeof sourceId === 'string' && typeof mangaId === 'string') {
dispatch(fetchMangaDetails({ sourceId, mangaId }))
dispatch(fetchChapters({ sourceId, mangaId }))
}
}, [sourceId, mangaId, dispatch])
if (!mangaDetails && loading) {
return <div className="text-center py-20">Loading...</div>
}
return (
<>
<Head>
<title>{getPageTitle(mangaDetails?.title || 'Manga Details')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiInformation}
title={mangaDetails?.title || 'Manga Details'}
main
>
<Link href={`/sources/browse/${sourceId}`} passHref legacyBehavior>
<BaseButton icon={mdiArrowLeft} label="Back to Source" color="whiteDark" />
</Link>
</SectionTitleLineWithButton>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div className="md:col-span-1">
<CardBox className="overflow-hidden p-0">
<img
src={mangaDetails?.coverUrl || 'https://via.placeholder.com/512x720'}
alt={mangaDetails?.title}
className="w-full h-auto"
/>
</CardBox>
</div>
<div className="md:col-span-3">
<CardBox className="h-full">
<h1 className="text-3xl font-bold mb-4 dark:text-white">{mangaDetails?.title}</h1>
<div className="flex flex-wrap gap-2 mb-4">
{mangaDetails?.author && (
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
Author: {mangaDetails.author}
</span>
)}
{mangaDetails?.artist && (
<span className="px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm">
Artist: {mangaDetails.artist}
</span>
)}
<span className="px-3 py-1 bg-gray-100 text-gray-800 rounded-full text-sm">
Source: {mangaDetails?.source}
</span>
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
Status: {mangaDetails?.status}
</span>
</div>
<div className="prose dark:prose-invert max-w-none">
<p className="text-gray-600 dark:text-slate-300">
{mangaDetails?.description || 'No description available.'}
</p>
</div>
</CardBox>
</div>
</div>
<CardBox title="Chapters">
<div className="divide-y dark:divide-slate-700 max-h-[600px] overflow-y-auto">
{chapters.map((chapter: any) => (
<div key={chapter.id} className="py-3 flex justify-between items-center group">
<div>
<span className="font-semibold dark:text-white">Chapter {chapter.chapter}</span>
{chapter.title && <span className="ml-2 text-gray-500 text-sm">- {chapter.title}</span>}
</div>
<Link href={`/reader/${sourceId}/${chapter.id}`} passHref legacyBehavior>
<BaseButton
color="info"
label="Read"
icon={mdiBookOpenVariant}
small
outline
/>
</Link>
</div>
))}
{chapters.length === 0 && (
<div className="py-10 text-center text-gray-500">
No chapters found for this manga.
</div>
)}
</div>
</CardBox>
</SectionMain>
</>
)
}
MangaDetailsPage.getLayout = function getLayout(page: React.ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default MangaDetailsPage

View File

@ -0,0 +1,145 @@
import { mdiMusic, mdiVideo, mdiMagnify, mdiDownload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useState } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import FormField from '../../components/FormField'
import { Field, Form, Formik } from 'formik'
import BaseButton from '../../components/BaseButton'
import axios from 'axios'
import BaseIcon from '../../components/BaseIcon'
const MediaSearchPage = () => {
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [type, setType] = useState('music')
const handleSearch = async (values) => {
setLoading(true)
try {
const response = await axios.get(`/media/search/${type}?query=${values.query}`)
setResults(response.data)
} catch (error) {
console.error('Search error:', error)
} finally {
setLoading(false)
}
}
const handleDownload = async (item) => {
try {
await axios.post('/media/download', {
type: item.type,
url: item.url,
title: item.title,
id: item.id
})
alert('Download started! Check the Downloads section.')
} catch (error) {
console.error('Download error:', error)
alert('Failed to start download.')
}
}
return (
<>
<Head>
<title>{getPageTitle('Media Search')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiMusic} title="Media Search" main>
{''}
</SectionTitleLineWithButton>
<CardBox className="mb-6">
<Formik initialValues={{ query: '' }} onSubmit={handleSearch}>
<Form>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<FormField label="Type">
<select
className="w-full h-10 border-gray-300 rounded shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="music">Music (Jamendo)</option>
<option value="video">Video (Pexels)</option>
</select>
</FormField>
<FormField label="Search Query">
<Field
name="query"
placeholder="Search for legal music or videos..."
className="w-full h-10 px-3 border border-gray-300 rounded shadow-sm"
/>
</FormField>
<div className="mb-4">
<BaseButton
type="submit"
color="info"
label="Search"
icon={mdiMagnify}
disabled={loading}
/>
</div>
</div>
</Form>
</Formik>
</CardBox>
{loading && <div className="text-center py-10">Searching...</div>}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{results.map((item) => (
<CardBox key={item.id} className="flex flex-col">
<div className="relative h-48 mb-4">
{item.image ? (
<img src={item.image} alt={item.title} className="w-full h-full object-cover rounded" />
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center rounded">
<BaseIcon path={item.type === 'music' ? mdiMusic : mdiVideo} size={48} />
</div>
)}
</div>
<div className="flex-grow">
<h3 className="font-bold text-lg line-clamp-1">{item.title}</h3>
<p className="text-gray-600 text-sm mb-2">{item.artist}</p>
{item.duration && (
<p className="text-xs text-gray-500 mb-4">
Duration: {Math.floor(item.duration / 60)}:{(item.duration % 60).toString().padStart(2, '0')}
</p>
)}
</div>
<div className="mt-4 flex space-x-2">
<BaseButton
color="success"
label="Download"
icon={mdiDownload}
onClick={() => handleDownload(item)}
small
/>
<a href={item.url} target="_blank" rel="noopener noreferrer">
<BaseButton color="info" label="Preview" icon={mdiVideo} small />
</a>
</div>
</CardBox>
))}
</div>
{!loading && results.length === 0 && (
<div className="text-center py-10 text-gray-500">
Search for something to see results.
</div>
)}
</SectionMain>
</>
)
}
MediaSearchPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default MediaSearchPage

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
const title = 'App Draft'
const title = 'manhwa Kai'
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {
@ -289,4 +289,4 @@ export default function PrivacyPolicy() {
PrivacyPolicy.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { mdiArrowLeft, mdiFormatVerticalAlignTop, mdiChevronLeft, mdiChevronRight } from '@mdi/js'
import LayoutAuthenticated from '../../../layouts/Authenticated'
import SectionMain from '../../../components/SectionMain'
import { useAppDispatch, useAppSelector } from '../../../stores/hooks'
import { fetchPages, clearReader } from '../../../stores/mangaSlice'
import BaseButton from '../../../components/BaseButton'
import { getPageTitle } from '../../../config'
import Link from 'next/link'
const ReaderPage = () => {
const router = useRouter()
const { sourceId, chapterId } = router.query
const dispatch = useAppDispatch()
const { pages, loading } = useAppSelector((state) => state.manga)
const [currentPage, setCurrentPage] = useState(0)
useEffect(() => {
if (sourceId && chapterId && typeof sourceId === 'string' && typeof chapterId === 'string') {
dispatch(fetchPages({ sourceId, chapterId }))
}
return () => {
dispatch(clearReader())
}
}, [sourceId, chapterId, dispatch])
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<>
<Head>
<title>{getPageTitle('Reading Chapter')}</title>
</Head>
<div className="bg-black min-h-screen text-white">
<div className="sticky top-0 z-50 bg-slate-900 bg-opacity-90 px-4 py-2 flex justify-between items-center">
<BaseButton
icon={mdiArrowLeft}
color="white"
onClick={() => router.back()}
small
label="Exit"
/>
<div className="text-sm font-semibold truncate max-w-xs">
Chapter {chapterId}
</div>
<BaseButton
icon={mdiFormatVerticalAlignTop}
color="white"
onClick={scrollToTop}
small
/>
</div>
<div className="max-w-3xl mx-auto py-4">
{loading ? (
<div className="flex flex-col items-center justify-center py-40">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-info-500"></div>
<p className="mt-4 text-gray-400">Loading pages...</p>
</div>
) : (
<div className="space-y-1">
{pages.map((url: string, index: number) => (
<div key={index} className="relative w-full flex justify-center bg-slate-950">
<img
src={url}
alt={`Page ${index + 1}`}
className="max-w-full h-auto"
loading="lazy"
/>
</div>
))}
</div>
)}
{!loading && pages.length === 0 && (
<div className="text-center py-40">
<p className="text-gray-500">No pages found for this chapter.</p>
<BaseButton
label="Go Back"
color="info"
onClick={() => router.back()}
className="mt-4"
/>
</div>
)}
</div>
<div className="fixed bottom-4 right-4 space-x-2">
<BaseButton
icon={mdiFormatVerticalAlignTop}
color="info"
onClick={scrollToTop}
rounded-full
/>
</div>
</div>
</>
)
}
// We use a blank layout for the reader or Guest layout if we want it fullscreen
ReaderPage.getLayout = function getLayout(page: React.ReactElement) {
return page // Custom layout included in component
}
export default ReaderPage

View File

@ -39,7 +39,7 @@ export default function Register() {
return (
<>
<Head>
<title>{getPageTitle('Login')}</title>
<title>{getPageTitle('Register')}</title>
</Head>
<SectionFullScreen bg='violet'>
@ -89,4 +89,4 @@ export default function Register() {
Register.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -0,0 +1,122 @@
import React, { useState, useEffect } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { mdiMagnify, mdiArrowLeft, mdiViewList } from '@mdi/js'
import LayoutAuthenticated from '../../../layouts/Authenticated'
import SectionMain from '../../../components/SectionMain'
import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton'
import CardBox from '../../../components/CardBox'
import { useAppDispatch, useAppSelector } from '../../../stores/hooks'
import { searchManga } from '../../../stores/mangaSlice'
import BaseButton from '../../../components/BaseButton'
import FormField from '../../../components/FormField'
import { getPageTitle } from '../../../config'
import Link from 'next/link'
const BrowseSourceIdPage = () => {
const router = useRouter()
const { id } = router.query
const dispatch = useAppDispatch()
const { searchResults, loading } = useAppSelector((state) => state.manga)
const [query, setQuery] = useState('')
const handleSearch = (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (id && typeof id === 'string') {
dispatch(searchManga({ query, sourceId: id }))
}
}
useEffect(() => {
if (id && typeof id === 'string' && searchResults.length === 0) {
dispatch(searchManga({ query: '', sourceId: id }))
}
}, [id, dispatch])
return (
<>
<Head>
<title>{getPageTitle('Browse Source')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiViewList}
title={`Browse Source`}
main
>
<Link href="/browse-sources" passHref legacyBehavior>
<BaseButton icon={mdiArrowLeft} label="Back to Sources" color="whiteDark" />
</Link>
</SectionTitleLineWithButton>
<CardBox className="mb-6">
<form onSubmit={handleSearch} className="flex gap-4">
<div className="flex-grow">
<FormField>
<input
type="text"
placeholder="Search manga in this source..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full px-4 py-2 border rounded dark:bg-slate-800 dark:border-slate-700"
/>
</FormField>
</div>
<BaseButton
type="submit"
color="info"
label="Search"
icon={mdiMagnify}
disabled={loading}
/>
</form>
</CardBox>
{loading ? (
<div className="text-center py-20">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-info-500"></div>
<p className="mt-2 text-gray-500">Fetching manga...</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{searchResults.map((manga: any) => (
<Link key={manga.id} href={`/manga/view/${id}/${manga.id}`} passHref legacyBehavior>
<div className="cursor-pointer group">
<div className="aspect-[3/4] overflow-hidden rounded-lg bg-gray-200 dark:bg-slate-800 relative">
{manga.coverUrl ? (
<img
src={manga.coverUrl}
alt={manga.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
No Cover
</div>
)}
</div>
<h3 className="mt-2 text-sm font-semibold line-clamp-2 dark:text-white">
{manga.title}
</h3>
</div>
</Link>
))}
</div>
)}
{!loading && searchResults.length === 0 && (
<div className="text-center py-20 text-gray-500">
No results found. Try a different search query.
</div>
)}
</SectionMain>
</>
)
}
BrowseSourceIdPage.getLayout = function getLayout(page: React.ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default BrowseSourceIdPage

View File

@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
const title = 'App Draft';
const title = 'manhwa Kai';
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {
@ -203,4 +203,4 @@ export default function PrivacyPolicy() {
PrivacyPolicy.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -0,0 +1,76 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import axios from 'axios'
interface MangaState {
searchResults: any[]
mangaDetails: any | null
chapters: any[]
pages: any[]
loading: boolean
error: string | null
}
const initialState: MangaState = {
searchResults: [],
mangaDetails: null,
chapters: [],
pages: [],
loading: false,
error: null,
}
export const searchManga = createAsyncThunk('manga/search', async (data: { query: string, sourceId: string }) => {
const response = await axios.get('/manga/search', { params: data });
return response.data;
})
export const fetchMangaDetails = createAsyncThunk('manga/fetchDetails', async (data: { mangaId: string, sourceId: string }) => {
const response = await axios.get(`/manga/details/${data.sourceId}/${data.mangaId}`);
return response.data;
})
export const fetchChapters = createAsyncThunk('manga/fetchChapters', async (data: { mangaId: string, sourceId: string }) => {
const response = await axios.get(`/manga/chapters/${data.sourceId}/${data.mangaId}`);
return response.data;
})
export const fetchPages = createAsyncThunk('manga/fetchPages', async (data: { chapterId: string, sourceId: string }) => {
const response = await axios.get(`/manga/pages/${data.sourceId}/${data.chapterId}`);
return response.data;
})
export const mangaSlice = createSlice({
name: 'manga',
initialState,
reducers: {
clearReader: (state) => {
state.pages = [];
}
},
extraReducers: (builder) => {
builder
.addCase(searchManga.pending, (state) => { state.loading = true; })
.addCase(searchManga.fulfilled, (state, action) => {
state.loading = false;
state.searchResults = action.payload;
})
.addCase(searchManga.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Search failed';
})
.addCase(fetchMangaDetails.fulfilled, (state, action) => {
state.mangaDetails = action.payload;
})
.addCase(fetchChapters.fulfilled, (state, action) => {
state.chapters = action.payload;
})
.addCase(fetchPages.pending, (state) => { state.loading = true; })
.addCase(fetchPages.fulfilled, (state, action) => {
state.loading = false;
state.pages = action.payload;
})
},
})
export const { clearReader } = mangaSlice.actions
export default mangaSlice.reducer

View File

@ -3,6 +3,7 @@ import styleReducer from './styleSlice';
import mainReducer from './mainSlice';
import authSlice from './authSlice';
import openAiSlice from './openAiSlice';
import mangaReducer from './mangaSlice';
import usersSlice from "./users/usersSlice";
import rolesSlice from "./roles/rolesSlice";
@ -34,6 +35,7 @@ export const store = configureStore({
main: mainReducer,
auth: authSlice,
openAi: openAiSlice,
manga: mangaReducer,
users: usersSlice,
roles: rolesSlice,
@ -64,4 +66,4 @@ reading_progress: reading_progressSlice,
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch
export type AppDispatch = typeof store.dispatch