Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1424e9761c | ||
|
|
4a312f7921 | ||
|
|
8d558b2070 | ||
|
|
d5087fc4e7 | ||
|
|
ef0c922762 | ||
|
|
c0c1673e46 |
6
502.html
6
502.html
@ -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>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
# App Draft
|
||||
# manhwa Kai
|
||||
|
||||
|
||||
## This project was generated by [Flatlogic Platform](https://flatlogic.com).
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
#App Draft - template backend,
|
||||
#manhwa Kai - template backend,
|
||||
|
||||
#### Run App on local machine:
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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')
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
30
backend/src/routes/manga.js
Normal file
30
backend/src/routes/manga.js
Normal 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;
|
||||
23
backend/src/routes/media.js
Normal file
23
backend/src/routes/media.js
Normal 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;
|
||||
168
backend/src/services/mangaScraper.js
Normal file
168
backend/src/services/mangaScraper.js
Normal 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;
|
||||
152
backend/src/services/media.js
Normal file
152
backend/src/services/media.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
@ -1,4 +1,4 @@
|
||||
# App Draft
|
||||
# manhwa Kai
|
||||
|
||||
## This project was generated by Flatlogic Platform.
|
||||
## Install
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 || ''
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
83
frontend/src/pages/browse-sources.tsx
Normal file
83
frontend/src/pages/browse-sources.tsx
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
};
|
||||
@ -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>;
|
||||
};
|
||||
};
|
||||
124
frontend/src/pages/manga/view/[sourceId]/[mangaId].tsx
Normal file
124
frontend/src/pages/manga/view/[sourceId]/[mangaId].tsx
Normal 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
|
||||
145
frontend/src/pages/media/index.tsx
Normal file
145
frontend/src/pages/media/index.tsx
Normal 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
|
||||
@ -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>;
|
||||
};
|
||||
};
|
||||
111
frontend/src/pages/reader/[sourceId]/[chapterId].tsx
Normal file
111
frontend/src/pages/reader/[sourceId]/[chapterId].tsx
Normal 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
|
||||
@ -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>;
|
||||
};
|
||||
};
|
||||
122
frontend/src/pages/sources/browse/[id].tsx
Normal file
122
frontend/src/pages/sources/browse/[id].tsx
Normal 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
|
||||
@ -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>;
|
||||
};
|
||||
};
|
||||
76
frontend/src/stores/mangaSlice.ts
Normal file
76
frontend/src/stores/mangaSlice.ts
Normal 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
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user