fixed a bug & replicated all changes to docker version
This commit is contained in:
@@ -182,14 +182,25 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
|
||||
const range = request.headers.range;
|
||||
|
||||
if (!range) {
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('Content-Type', 'video/mp4'); // o dinámico
|
||||
reply
|
||||
.header('Content-Length', stat.size)
|
||||
.header('Content-Type', 'video/mp4');
|
||||
return fs.createReadStream(file.file_path);
|
||||
}
|
||||
|
||||
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = Number(parts[0]);
|
||||
let end = parts[1] ? Number(parts[1]) : stat.size - 1;
|
||||
|
||||
if (
|
||||
Number.isNaN(start) ||
|
||||
Number.isNaN(end) ||
|
||||
start < 0 ||
|
||||
end < start ||
|
||||
end >= stat.size
|
||||
) {
|
||||
end = stat.size - 1;
|
||||
}
|
||||
|
||||
reply
|
||||
.status(206)
|
||||
@@ -199,6 +210,7 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
|
||||
.header('Content-Type', 'video/mp4');
|
||||
|
||||
return fs.createReadStream(file.file_path, { start, end });
|
||||
|
||||
}
|
||||
|
||||
type MatchBody = {
|
||||
|
||||
@@ -239,8 +239,9 @@ async function loadExtensions() {
|
||||
const select = document.getElementById('extension-select');
|
||||
let extensions = data.extensions || [];
|
||||
|
||||
// Añadimos local manualmente
|
||||
if (!extensions.includes('local')) extensions.push('local');
|
||||
if (extName === 'local' && !extensions.includes('local')) {
|
||||
extensions.push('local');
|
||||
}
|
||||
|
||||
select.innerHTML = '';
|
||||
extensions.forEach(ext => {
|
||||
@@ -251,8 +252,8 @@ async function loadExtensions() {
|
||||
|
||||
if (extName && extensions.includes(extName)) {
|
||||
select.value = extName;
|
||||
} else {
|
||||
select.value = 'local'; // Default a local
|
||||
} else if (extensions.length > 0) {
|
||||
select.value = extensions[0];
|
||||
}
|
||||
|
||||
currentExtension = select.value;
|
||||
|
||||
19
docker/package-lock.json
generated
19
docker/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"cheerio": "^1.1.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"fastify": "^5.6.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -604,6 +605,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -1780,6 +1787,18 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"cheerio": "^1.1.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"fastify": "^5.6.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-cron": "^4.2.1",
|
||||
|
||||
@@ -10,6 +10,7 @@ const { initDatabase } = require("./dist/shared/database");
|
||||
const { loadExtensions } = require("./dist/shared/extensions");
|
||||
const {refreshTrendingAnime, refreshTopAiringAnime} = require("./dist/api/anime/anime.service");
|
||||
const {refreshPopularBooks, refreshTrendingBooks} = require("./dist/api/books/books.service");
|
||||
const { ensureConfigFile } = require("./dist/shared/config");
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
dotenv.config();
|
||||
@@ -23,6 +24,8 @@ const galleryRoutes = require("./dist/api/gallery/gallery.routes");
|
||||
const userRoutes = require("./dist/api/user/user.routes");
|
||||
const listRoutes = require("./dist/api/list/list.routes");
|
||||
const anilistRoute = require("./dist/api/anilist/anilist");
|
||||
const localRoutes = require("./dist/api/local/local.routes");
|
||||
const configRoutes = require("./dist/api/config/config.routes");
|
||||
|
||||
fastify.addHook("preHandler", async (request) => {
|
||||
const auth = request.headers.authorization;
|
||||
@@ -63,15 +66,19 @@ fastify.register(galleryRoutes, { prefix: "/api" });
|
||||
fastify.register(userRoutes, { prefix: "/api" });
|
||||
fastify.register(anilistRoute, { prefix: "/api" });
|
||||
fastify.register(listRoutes, { prefix: "/api" });
|
||||
fastify.register(localRoutes, { prefix: "/api" });
|
||||
fastify.register(configRoutes, { prefix: "/api" });
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
ensureConfigFile()
|
||||
initDatabase("anilist");
|
||||
initDatabase("favorites");
|
||||
initDatabase("cache");
|
||||
initDatabase("userdata");
|
||||
initDatabase("local_library");
|
||||
|
||||
const refreshAll = async () => {
|
||||
await refreshTrendingAnime();
|
||||
|
||||
43
docker/src/api/config/config.controller.ts
Normal file
43
docker/src/api/config/config.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import {getConfig, setConfig} from '../../shared/config';
|
||||
|
||||
export async function getFullConfig(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
return getConfig();
|
||||
} catch (err) {
|
||||
return { error: "Error loading config" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigSection(req: FastifyRequest<{ Params: { section: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { section } = req.params;
|
||||
const config = getConfig();
|
||||
|
||||
if (config[section] === undefined) {
|
||||
return { error: "Section not found" };
|
||||
}
|
||||
|
||||
return { [section]: config[section] };
|
||||
} catch (err) {
|
||||
return { error: "Error loading config section" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConfig(req: FastifyRequest<{ Body: any }>, reply: FastifyReply) {
|
||||
try {
|
||||
return setConfig(req.body);
|
||||
} catch (err) {
|
||||
return { error: "Error updating config" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConfigSection(req: FastifyRequest<{ Params: { section: string }, Body: any }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { section } = req.params;
|
||||
const updatedConfig = setConfig({ [section]: req.body });
|
||||
return { [section]: updatedConfig[section] };
|
||||
} catch (err) {
|
||||
return { error: "Error updating config section" };
|
||||
}
|
||||
}
|
||||
11
docker/src/api/config/config.routes.ts
Normal file
11
docker/src/api/config/config.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './config.controller';
|
||||
|
||||
async function configRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/config', controller.getFullConfig);
|
||||
fastify.get('/config/:section', controller.getConfigSection);
|
||||
fastify.post('/config', controller.updateConfig);
|
||||
fastify.post('/config/:section', controller.updateConfigSection);
|
||||
}
|
||||
|
||||
export default configRoutes;
|
||||
247
docker/src/api/local/local.controller.ts
Normal file
247
docker/src/api/local/local.controller.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { getConfig as loadConfig, setConfig as saveConfig } from '../../shared/config.js';
|
||||
import { queryOne, queryAll, run } from '../../shared/database.js';
|
||||
import crypto from 'crypto';
|
||||
import fs from "fs";
|
||||
import { PathLike } from "node:fs";
|
||||
import path from "path";
|
||||
import {getAnimeById, searchAnimeLocal} from "../anime/anime.service";
|
||||
import {getBookById, searchBooksLocal} from "../books/books.service";
|
||||
|
||||
type SetConfigBody = {
|
||||
library?: {
|
||||
anime?: string | null;
|
||||
manga?: string | null;
|
||||
novels?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type ScanQuery = {
|
||||
mode?: 'full' | 'incremental';
|
||||
};
|
||||
|
||||
type Params = {
|
||||
type: 'anime' | 'manga' | 'novels';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
async function resolveEntryMetadata(entry: any, type: string) {
|
||||
let metadata = null;
|
||||
let matchedId = entry.matched_id;
|
||||
|
||||
if (!matchedId) {
|
||||
const query = entry.folder_name;
|
||||
|
||||
const results = type === 'anime'
|
||||
? await searchAnimeLocal(query)
|
||||
: await searchBooksLocal(query);
|
||||
|
||||
const first = results?.[0];
|
||||
|
||||
if (first?.id) {
|
||||
matchedId = first.id;
|
||||
|
||||
await run(
|
||||
`UPDATE local_entries
|
||||
SET matched_id = ?, matched_source = 'anilist'
|
||||
WHERE id = ?`,
|
||||
[matchedId, entry.id],
|
||||
'local_library'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedId) {
|
||||
metadata = type === 'anime'
|
||||
? await getAnimeById(matchedId)
|
||||
: await getBookById(matchedId);
|
||||
}
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
matched: !!matchedId,
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) {
|
||||
try {
|
||||
const mode = request.query.mode || 'incremental';
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.library) {
|
||||
return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' });
|
||||
}
|
||||
|
||||
if (mode === 'full') {
|
||||
await run(`DELETE FROM local_files`, [], 'local_library');
|
||||
await run(`DELETE FROM local_entries`, [], 'local_library');
|
||||
}
|
||||
|
||||
for (const [type, basePath] of Object.entries(config.library)) {
|
||||
if (!basePath || !fs.existsSync(<PathLike>basePath)) continue;
|
||||
|
||||
const dirs = fs.readdirSync(<string>basePath, { withFileTypes: true }).filter(d => d.isDirectory());
|
||||
|
||||
for (const dir of dirs) {
|
||||
const fullPath = path.join(<string>basePath, dir.name);
|
||||
const id = crypto.createHash('sha1').update(fullPath).digest('hex');
|
||||
const now = Date.now();
|
||||
|
||||
const existing = await queryOne(`SELECT id FROM local_entries WHERE id = ?`, [id], 'local_library');
|
||||
|
||||
if (existing) {
|
||||
await run(`UPDATE local_entries SET last_scan = ? WHERE id = ?`, [now, id], 'local_library');
|
||||
await run(`DELETE FROM local_files WHERE entry_id = ?`, [id], 'local_library');
|
||||
} else {
|
||||
await run(
|
||||
`INSERT INTO local_entries (id, type, path, folder_name, last_scan) VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, type, fullPath, dir.name, now],
|
||||
'local_library'
|
||||
);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(fullPath, { withFileTypes: true })
|
||||
.filter(f => f.isFile())
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let unit = 1;
|
||||
|
||||
for (const file of files) {
|
||||
await run(
|
||||
`INSERT INTO local_files (id, entry_id, file_path, unit_number)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[crypto.randomUUID(), id, path.join(fullPath, file.name), unit],
|
||||
'local_library'
|
||||
);
|
||||
unit++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status: 'OK' };
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { type } = request.params;
|
||||
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
|
||||
|
||||
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
|
||||
} catch {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { type, id } = request.params as { type: string, id: string };
|
||||
|
||||
const entry = await queryOne(
|
||||
`SELECT * FROM local_entries WHERE matched_id = ? AND type = ?`,
|
||||
[Number(id), type],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const [details, files] = await Promise.all([
|
||||
resolveEntryMetadata(entry, type),
|
||||
queryAll(
|
||||
`SELECT id, file_path, unit_number FROM local_files WHERE entry_id = ? ORDER BY unit_number ASC`,
|
||||
[id],
|
||||
'local_library'
|
||||
)
|
||||
]);
|
||||
|
||||
return { ...details, files };
|
||||
} catch {
|
||||
return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamUnit(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { id, unit } = request.params as any;
|
||||
|
||||
const file = await queryOne(
|
||||
`SELECT file_path FROM local_files WHERE entry_id = ? AND unit_number = ?`,
|
||||
[id, unit],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
if (!file || !fs.existsSync(file.file_path)) {
|
||||
return reply.status(404).send({ error: 'FILE_NOT_FOUND' });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(file.file_path);
|
||||
const range = request.headers.range;
|
||||
|
||||
if (!range) {
|
||||
reply
|
||||
.header('Content-Length', stat.size)
|
||||
.header('Content-Type', 'video/mp4');
|
||||
return fs.createReadStream(file.file_path);
|
||||
}
|
||||
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = Number(parts[0]);
|
||||
let end = parts[1] ? Number(parts[1]) : stat.size - 1;
|
||||
|
||||
if (
|
||||
Number.isNaN(start) ||
|
||||
Number.isNaN(end) ||
|
||||
start < 0 ||
|
||||
end < start ||
|
||||
end >= stat.size
|
||||
) {
|
||||
end = stat.size - 1;
|
||||
}
|
||||
|
||||
reply
|
||||
.status(206)
|
||||
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Content-Length', end - start + 1)
|
||||
.header('Content-Type', 'video/mp4');
|
||||
|
||||
return fs.createReadStream(file.file_path, { start, end });
|
||||
|
||||
}
|
||||
|
||||
type MatchBody = {
|
||||
source: 'anilist';
|
||||
matched_id: number | null;
|
||||
};
|
||||
|
||||
export async function matchEntry(
|
||||
request: FastifyRequest<{ Body: MatchBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { id, type } = request.params as any;
|
||||
const { source, matched_id } = request.body;
|
||||
|
||||
const entry = await queryOne(
|
||||
`SELECT id FROM local_entries WHERE id = ? AND type = ?`,
|
||||
[id, type],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' });
|
||||
}
|
||||
|
||||
await run(
|
||||
`UPDATE local_entries
|
||||
SET matched_source = ?, matched_id = ?
|
||||
WHERE id = ?`,
|
||||
[source, matched_id, id],
|
||||
'local_library'
|
||||
);
|
||||
|
||||
return { status: 'OK', matched: !!matched_id };
|
||||
}
|
||||
12
docker/src/api/local/local.routes.ts
Normal file
12
docker/src/api/local/local.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './local.controller';
|
||||
|
||||
async function localRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/library/scan', controller.scanLibrary);
|
||||
fastify.get('/library/:type', controller.listEntries);
|
||||
fastify.get('/library/:type/:id', controller.getEntry);
|
||||
fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit);
|
||||
fastify.post('/library/:type/:id/match', controller.matchEntry);
|
||||
}
|
||||
|
||||
export default localRoutes;
|
||||
@@ -1,6 +1,7 @@
|
||||
let animeData = null;
|
||||
let extensionName = null;
|
||||
let animeId = null;
|
||||
let isLocal = false;
|
||||
|
||||
const episodePagination = Object.create(PaginationManager);
|
||||
episodePagination.init(12, renderEpisodes);
|
||||
@@ -13,6 +14,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setupEpisodeSearch();
|
||||
});
|
||||
|
||||
function markAsLocal() {
|
||||
isLocal = true;
|
||||
const pill = document.getElementById('local-pill');
|
||||
if (!pill) return;
|
||||
|
||||
pill.textContent = 'Local';
|
||||
pill.style.display = 'inline-flex';
|
||||
pill.style.background = 'rgba(34,197,94,.2)';
|
||||
pill.style.color = '#22c55e';
|
||||
pill.style.borderColor = 'rgba(34,197,94,.3)';
|
||||
}
|
||||
|
||||
async function checkLocalLibraryEntry() {
|
||||
try {
|
||||
const res = await fetch(`/api/library/anime/${animeId}`);
|
||||
if (!res.ok) return;
|
||||
|
||||
markAsLocal();
|
||||
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnime() {
|
||||
try {
|
||||
|
||||
@@ -24,6 +48,7 @@ async function loadAnime() {
|
||||
|
||||
extensionName = urlData.extensionName;
|
||||
animeId = urlData.entityId;
|
||||
await checkLocalLibraryEntry();
|
||||
|
||||
const fetchUrl = extensionName
|
||||
? `/api/anime/${animeId}?source=${extensionName}`
|
||||
@@ -142,8 +167,8 @@ function setupWatchButton() {
|
||||
const watchBtn = document.getElementById('watch-btn');
|
||||
if (watchBtn) {
|
||||
watchBtn.onclick = () => {
|
||||
const url = URLUtils.buildWatchUrl(animeId, 1, extensionName);
|
||||
window.location.href = url;
|
||||
const source = isLocal ? 'local' : (extensionName || 'anilist');
|
||||
window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -226,8 +251,8 @@ function createEpisodeButton(num, container) {
|
||||
btn.className = 'episode-btn';
|
||||
btn.innerText = `Ep ${num}`;
|
||||
btn.onclick = () => {
|
||||
const url = URLUtils.buildWatchUrl(animeId, num, extensionName);
|
||||
window.location.href = url;
|
||||
const source = isLocal ? 'local' : (extensionName || 'anilist');
|
||||
window.location.href = URLUtils.buildWatchUrl(animeId, num, source);
|
||||
};
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ let currentExtension = '';
|
||||
let plyrInstance;
|
||||
let hlsInstance;
|
||||
let totalEpisodes = 0;
|
||||
let animeTitle = "";
|
||||
let aniSkipData = null;
|
||||
|
||||
let isAnilist = false;
|
||||
@@ -17,13 +18,28 @@ const firstKey = params.keys().next().value;
|
||||
let extName;
|
||||
if (firstKey) extName = firstKey;
|
||||
|
||||
const href = extName
|
||||
// URL de retroceso: Si es local, volvemos a la vista de Anilist normal
|
||||
const href = (extName && extName !== 'local')
|
||||
? `/anime/${extName}/${animeId}`
|
||||
: `/anime/${animeId}`;
|
||||
|
||||
document.getElementById('back-link').href = href;
|
||||
document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`;
|
||||
|
||||
|
||||
let localEntryId = null;
|
||||
|
||||
async function checkLocal() {
|
||||
try {
|
||||
const res = await fetch(`/api/library/anime/${animeId}`);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
localEntryId = data.id;
|
||||
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadAniSkip(malId, episode, duration) {
|
||||
try {
|
||||
const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`);
|
||||
@@ -37,9 +53,10 @@ async function loadAniSkip(malId, episode, duration) {
|
||||
}
|
||||
|
||||
async function loadMetadata() {
|
||||
checkLocal();
|
||||
try {
|
||||
const extQuery = extName ? `?source=${extName}` : "?source=anilist";
|
||||
const res = await fetch(`/api/anime/${animeId}${extQuery}`);
|
||||
const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`;
|
||||
const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
@@ -49,13 +66,7 @@ async function loadMetadata() {
|
||||
|
||||
const isAnilistFormat = data.title && (data.title.romaji || data.title.english);
|
||||
|
||||
let title = '';
|
||||
let description = '';
|
||||
let coverImage = '';
|
||||
let averageScore = '';
|
||||
let format = '';
|
||||
let seasonYear = '';
|
||||
let season = '';
|
||||
let title = '', description = '', coverImage = '', averageScore = '', format = '', seasonYear = '', season = '';
|
||||
|
||||
if (isAnilistFormat) {
|
||||
title = data.title.romaji || data.title.english || data.title.native || 'Anime Title';
|
||||
@@ -85,6 +96,7 @@ async function loadMetadata() {
|
||||
|
||||
document.getElementById('anime-title-details').innerText = title;
|
||||
document.getElementById('anime-title-details2').innerText = title;
|
||||
animeTitle = title;
|
||||
document.title = `Watching ${title} - Ep ${currentEpisode}`;
|
||||
|
||||
const tempDiv = document.createElement('div');
|
||||
@@ -96,7 +108,8 @@ async function loadMetadata() {
|
||||
document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--');
|
||||
document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg';
|
||||
|
||||
if (extName) {
|
||||
// Solo cargamos episodios de extensión si hay extensión real y no es local
|
||||
if (extName && extName !== 'local') {
|
||||
await loadExtensionEpisodes();
|
||||
} else {
|
||||
if (data.nextAiringEpisode?.episode) {
|
||||
@@ -108,12 +121,7 @@ async function loadMetadata() {
|
||||
}
|
||||
const simpleEpisodes = [];
|
||||
for (let i = 1; i <= totalEpisodes; i++) {
|
||||
simpleEpisodes.push({
|
||||
number: i,
|
||||
title: null,
|
||||
thumbnail: null,
|
||||
isDub: false
|
||||
});
|
||||
simpleEpisodes.push({ number: i, title: null, thumbnail: null, isDub: false });
|
||||
}
|
||||
populateEpisodeCarousel(simpleEpisodes);
|
||||
}
|
||||
@@ -128,72 +136,30 @@ async function loadMetadata() {
|
||||
}
|
||||
|
||||
async function applyAniSkip(video) {
|
||||
if (!isAnilist || !malId) {
|
||||
console.log('AniSkip disabled: isAnilist=' + isAnilist + ', malId=' + malId);
|
||||
return;
|
||||
}
|
||||
if (!isAnilist || !malId) return;
|
||||
|
||||
console.log('Loading AniSkip for MAL ID:', malId, 'Episode:', currentEpisode);
|
||||
aniSkipData = await loadAniSkip(malId, currentEpisode, Math.floor(video.duration));
|
||||
|
||||
aniSkipData = await loadAniSkip(
|
||||
malId,
|
||||
currentEpisode,
|
||||
Math.floor(video.duration)
|
||||
);
|
||||
if (!aniSkipData || aniSkipData.length === 0) return;
|
||||
|
||||
console.log('AniSkip data received:', aniSkipData);
|
||||
|
||||
if (!aniSkipData || aniSkipData.length === 0) {
|
||||
console.log('No AniSkip data available');
|
||||
return;
|
||||
}
|
||||
|
||||
let op, ed;
|
||||
const markers = [];
|
||||
|
||||
aniSkipData.forEach(item => {
|
||||
const { startTime, endTime } = item.interval;
|
||||
|
||||
if (item.skipType === 'op') {
|
||||
op = { start: startTime, end: endTime };
|
||||
markers.push({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
label: 'Opening'
|
||||
label: item.skipType === 'op' ? 'Opening' : 'Ending'
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Opening found:', startTime, '-', endTime);
|
||||
}
|
||||
|
||||
if (item.skipType === 'ed') {
|
||||
ed = { start: startTime, end: endTime };
|
||||
markers.push({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
label: 'Ending'
|
||||
});
|
||||
|
||||
console.log('Ending found:', startTime, '-', endTime);
|
||||
}
|
||||
});
|
||||
|
||||
// Crear markers visuales en el DOM
|
||||
if (plyrInstance && markers.length > 0) {
|
||||
console.log('Creating visual markers:', markers);
|
||||
|
||||
// Esperar a que el player esté completamente cargado
|
||||
setTimeout(() => {
|
||||
const progressContainer = document.querySelector('.plyr__progress');
|
||||
if (!progressContainer) {
|
||||
console.error('Progress container not found');
|
||||
return;
|
||||
}
|
||||
if (!progressContainer) return;
|
||||
|
||||
// Eliminar markers anteriores si existen
|
||||
const oldMarkers = progressContainer.querySelector('.plyr__markers');
|
||||
if (oldMarkers) oldMarkers.remove();
|
||||
|
||||
// Crear contenedor de markers
|
||||
const markersContainer = document.createElement('div');
|
||||
markersContainer.className = 'plyr__markers';
|
||||
|
||||
@@ -215,56 +181,36 @@ async function applyAniSkip(video) {
|
||||
|
||||
markersContainer.appendChild(markerElement);
|
||||
});
|
||||
|
||||
|
||||
progressContainer.appendChild(markersContainer);
|
||||
console.log('Visual markers created successfully');
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExtensionEpisodes() {
|
||||
try {
|
||||
const extQuery = extName ? `?source=${extName}` : "?source=anilist";
|
||||
const res = await fetch(`/api/anime/${animeId}/episodes${extQuery}`);
|
||||
const res = await fetch(`/api/anime/${animeId}/episodes?source=${extName}`);
|
||||
const data = await res.json();
|
||||
|
||||
totalEpisodes = Array.isArray(data) ? data.length : 0;
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
populateEpisodeCarousel(data);
|
||||
} else {
|
||||
const fallback = [];
|
||||
for (let i = 1; i <= totalEpisodes; i++) {
|
||||
fallback.push({ number: i, title: null, thumbnail: null });
|
||||
}
|
||||
populateEpisodeCarousel(fallback);
|
||||
}
|
||||
populateEpisodeCarousel(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
console.error("Error cargando episodios por extensión:", e);
|
||||
totalEpisodes = 0;
|
||||
console.error("Error cargando episodios:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function populateEpisodeCarousel(episodesData) {
|
||||
const carousel = document.getElementById('episode-carousel');
|
||||
if (!carousel) return;
|
||||
|
||||
carousel.innerHTML = '';
|
||||
|
||||
episodesData.forEach((ep, index) => {
|
||||
const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1);
|
||||
if (!epNumber) return;
|
||||
|
||||
const extParam = extName ? `?${extName}` : "";
|
||||
const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
|
||||
const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== '';
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `/watch/${animeId}/${epNumber}${extParam}`;
|
||||
link.classList.add('carousel-item');
|
||||
link.dataset.episode = epNumber;
|
||||
|
||||
if (!hasThumbnail) link.classList.add('no-thumbnail');
|
||||
if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel');
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
@@ -272,21 +218,15 @@ function populateEpisodeCarousel(episodesData) {
|
||||
|
||||
if (hasThumbnail) {
|
||||
const img = document.createElement('img');
|
||||
img.classList.add('carousel-item-img');
|
||||
img.src = ep.thumbnail;
|
||||
img.alt = `Episode ${epNumber} Thumbnail`;
|
||||
img.classList.add('carousel-item-img');
|
||||
imgContainer.appendChild(img);
|
||||
}
|
||||
|
||||
link.appendChild(imgContainer);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.classList.add('carousel-item-info');
|
||||
|
||||
const title = document.createElement('p');
|
||||
title.innerText = `Ep ${epNumber}: ${ep.title || 'Untitled'}`;
|
||||
|
||||
info.appendChild(title);
|
||||
info.innerHTML = `<p>Ep ${epNumber}: ${ep.title || 'Untitled'}</p>`;
|
||||
link.appendChild(info);
|
||||
carousel.appendChild(link);
|
||||
});
|
||||
@@ -297,28 +237,27 @@ async function loadExtensions() {
|
||||
const res = await fetch('/api/extensions/anime');
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('extension-select');
|
||||
let extensions = data.extensions || [];
|
||||
|
||||
if (extName === 'local' && !extensions.includes('local')) {
|
||||
extensions.push('local');
|
||||
}
|
||||
|
||||
if (data.extensions && data.extensions.length > 0) {
|
||||
select.innerHTML = '';
|
||||
data.extensions.forEach(ext => {
|
||||
extensions.forEach(ext => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = opt.innerText = ext;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
if (typeof extName === 'string' && data.extensions.includes(extName)) {
|
||||
if (extName && extensions.includes(extName)) {
|
||||
select.value = extName;
|
||||
} else {
|
||||
select.selectedIndex = 0;
|
||||
} else if (extensions.length > 0) {
|
||||
select.value = extensions[0];
|
||||
}
|
||||
|
||||
currentExtension = select.value;
|
||||
onExtensionChange();
|
||||
} else {
|
||||
select.innerHTML = '<option>No Extensions</option>';
|
||||
select.disabled = true;
|
||||
setLoading("No anime extensions found.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Extension Error:", error);
|
||||
}
|
||||
@@ -327,83 +266,69 @@ async function loadExtensions() {
|
||||
async function onExtensionChange() {
|
||||
const select = document.getElementById('extension-select');
|
||||
currentExtension = select.value;
|
||||
setLoading("Fetching extension settings...");
|
||||
|
||||
if (currentExtension === 'local') {
|
||||
document.getElementById('sd-toggle').style.display = 'none';
|
||||
document.getElementById('server-select').style.display = 'none';
|
||||
loadStream();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading("Fetching extension settings...");
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/${currentExtension}/settings`);
|
||||
const settings = await res.json();
|
||||
|
||||
const toggle = document.getElementById('sd-toggle');
|
||||
if (settings.supportsDub) {
|
||||
toggle.style.display = 'flex';
|
||||
toggle.style.display = settings.supportsDub ? 'flex' : 'none';
|
||||
setAudioMode('sub');
|
||||
} else {
|
||||
toggle.style.display = 'none';
|
||||
setAudioMode('sub');
|
||||
}
|
||||
|
||||
const serverSelect = document.getElementById('server-select');
|
||||
serverSelect.innerHTML = '';
|
||||
if (settings.episodeServers && settings.episodeServers.length > 0) {
|
||||
if (settings.episodeServers?.length > 0) {
|
||||
settings.episodeServers.forEach(srv => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = srv;
|
||||
opt.innerText = srv;
|
||||
opt.value = opt.innerText = srv;
|
||||
serverSelect.appendChild(opt);
|
||||
});
|
||||
serverSelect.style.display = 'block';
|
||||
} else {
|
||||
serverSelect.style.display = 'none';
|
||||
}
|
||||
|
||||
loadStream();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLoading("Failed to load extension settings.");
|
||||
setLoading("Failed to load settings.");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAudioMode() {
|
||||
const newMode = audioMode === 'sub' ? 'dub' : 'sub';
|
||||
setAudioMode(newMode);
|
||||
loadStream();
|
||||
}
|
||||
|
||||
function setAudioMode(mode) {
|
||||
audioMode = mode;
|
||||
const toggle = document.getElementById('sd-toggle');
|
||||
const subOpt = document.getElementById('opt-sub');
|
||||
const dubOpt = document.getElementById('opt-dub');
|
||||
|
||||
toggle.setAttribute('data-state', mode);
|
||||
subOpt.classList.toggle('active', mode === 'sub');
|
||||
dubOpt.classList.toggle('active', mode === 'dub');
|
||||
}
|
||||
|
||||
async function loadStream() {
|
||||
if (!currentExtension) return;
|
||||
|
||||
if (currentExtension === 'local') {
|
||||
if (!localEntryId) {
|
||||
setLoading("No existe en local");
|
||||
return;
|
||||
}
|
||||
|
||||
const localUrl = `/api/library/stream/anime/${localEntryId}/${currentEpisode}`;
|
||||
playVideo(localUrl, []);
|
||||
document.getElementById('loading-overlay').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const serverSelect = document.getElementById('server-select');
|
||||
const server = serverSelect.value || "default";
|
||||
|
||||
setLoading(`Loading stream (${audioMode})...`);
|
||||
|
||||
try {
|
||||
let sourc = "&source=anilist";
|
||||
if (extName){
|
||||
sourc = `&source=${extName}`;
|
||||
}
|
||||
const sourc = (extName && extName !== 'local') ? `&source=${extName}` : "&source=anilist";
|
||||
const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
setLoading(`Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.videoSources || data.videoSources.length === 0) {
|
||||
setLoading("No video sources found.");
|
||||
if (data.error || !data.videoSources?.length) {
|
||||
setLoading(data.error || "No video sources.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -415,33 +340,31 @@ async function loadStream() {
|
||||
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
|
||||
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
|
||||
|
||||
playVideo(proxyUrl, data.videoSources[0].subtitles || data.subtitles);
|
||||
playVideo(proxyUrl, source.subtitles || data.subtitles || []);
|
||||
document.getElementById('loading-overlay').style.display = 'none';
|
||||
} catch (error) {
|
||||
setLoading("Stream error. Check console.");
|
||||
console.error(error);
|
||||
setLoading("Stream error.");
|
||||
}
|
||||
}
|
||||
|
||||
function playVideo(url, subtitles = []) {
|
||||
const video = document.getElementById('player');
|
||||
const isLocal = url.includes('/api/library/stream/');
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
if (!isLocal && Hls.isSupported()) {
|
||||
if (hlsInstance) hlsInstance.destroy();
|
||||
hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false });
|
||||
hlsInstance.loadSource(url);
|
||||
hlsInstance.attachMedia(video);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
} else {
|
||||
if (hlsInstance) hlsInstance.destroy();
|
||||
video.src = url;
|
||||
}
|
||||
|
||||
if (plyrInstance) plyrInstance.destroy();
|
||||
|
||||
const existingTracks = video.querySelectorAll('track');
|
||||
existingTracks.forEach(track => track.remove());
|
||||
while (video.textTracks.length > 0) video.removeChild(video.textTracks[0]);
|
||||
|
||||
subtitles.forEach(sub => {
|
||||
if (!sub.url) return;
|
||||
const track = document.createElement('track');
|
||||
track.kind = 'captions';
|
||||
track.label = sub.language || 'Unknown';
|
||||
@@ -454,74 +377,23 @@ function playVideo(url, subtitles = []) {
|
||||
plyrInstance = new Plyr(video, {
|
||||
captions: { active: true, update: true, language: 'en' },
|
||||
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
|
||||
settings: ['captions', 'quality', 'speed'],
|
||||
markers: {
|
||||
enabled: true,
|
||||
points: []
|
||||
}
|
||||
settings: ['captions', 'quality', 'speed']
|
||||
});
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
applyAniSkip(video);
|
||||
});
|
||||
|
||||
let alreadyTriggered = false;
|
||||
|
||||
video.addEventListener('timeupdate', () => {
|
||||
if (!video.duration) return;
|
||||
|
||||
const percent = (video.currentTime / video.duration) * 100;
|
||||
|
||||
if (percent >= 80 && !alreadyTriggered) {
|
||||
alreadyTriggered = true;
|
||||
sendProgress();
|
||||
}
|
||||
});
|
||||
|
||||
video.play().catch(() => console.log("Autoplay blocked"));
|
||||
}
|
||||
|
||||
function setLoading(message) {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
const text = document.getElementById('loading-text');
|
||||
overlay.style.display = 'flex';
|
||||
text.innerText = message;
|
||||
}
|
||||
|
||||
const extParam = extName ? `?${extName}` : "";
|
||||
|
||||
document.getElementById('prev-btn').onclick = () => {
|
||||
if (currentEpisode > 1) {
|
||||
window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('next-btn').onclick = () => {
|
||||
if (currentEpisode < totalEpisodes || totalEpisodes === 0) {
|
||||
window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (currentEpisode <= 1) {
|
||||
document.getElementById('prev-btn').disabled = true;
|
||||
video.addEventListener('loadedmetadata', () => applyAniSkip(video));
|
||||
}
|
||||
|
||||
async function sendProgress() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const source = extName
|
||||
? extName
|
||||
: "anilist";
|
||||
const source = (extName && extName !== 'local') ? extName : "anilist";
|
||||
|
||||
const body = {
|
||||
entry_id: animeId,
|
||||
source: source,
|
||||
entry_type: "ANIME",
|
||||
status: 'CURRENT',
|
||||
progress: source === 'anilist'
|
||||
? Math.floor(currentEpisode)
|
||||
: currentEpisode
|
||||
progress: currentEpisode
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -538,5 +410,38 @@ async function sendProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
// Botones y Toggle
|
||||
document.getElementById('sd-toggle').onclick = () => {
|
||||
audioMode = audioMode === 'sub' ? 'dub' : 'sub';
|
||||
setAudioMode(audioMode);
|
||||
loadStream();
|
||||
};
|
||||
|
||||
function setAudioMode(mode) {
|
||||
const toggle = document.getElementById('sd-toggle');
|
||||
toggle.setAttribute('data-state', mode);
|
||||
document.getElementById('opt-sub').classList.toggle('active', mode === 'sub');
|
||||
document.getElementById('opt-dub').classList.toggle('active', mode === 'dub');
|
||||
}
|
||||
|
||||
function setLoading(message) {
|
||||
document.getElementById('loading-text').innerText = message;
|
||||
document.getElementById('loading-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
const extParam = (extName && extName !== 'local') ? `?${extName}` : "";
|
||||
document.getElementById('prev-btn').onclick = () => {
|
||||
if (currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
|
||||
};
|
||||
document.getElementById('next-btn').onclick = () => {
|
||||
if (currentEpisode < totalEpisodes || totalEpisodes === 0) window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
|
||||
};
|
||||
|
||||
if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
|
||||
|
||||
setInterval(() => {
|
||||
if (plyrInstance && !plyrInstance.paused) sendProgress();
|
||||
}, 60000);
|
||||
|
||||
loadMetadata();
|
||||
loadExtensions();
|
||||
@@ -43,6 +43,43 @@ async function loadMeUI() {
|
||||
}
|
||||
}
|
||||
|
||||
// Variable para saber si el modal ya fue cargado
|
||||
let settingsModalLoaded = false;
|
||||
|
||||
document.getElementById('nav-settings').addEventListener('click', openSettings)
|
||||
|
||||
async function openSettings() {
|
||||
if (!settingsModalLoaded) {
|
||||
try {
|
||||
const res = await fetch('/views/components/settings-modal.html')
|
||||
const html = await res.text()
|
||||
document.body.insertAdjacentHTML('beforeend', html)
|
||||
settingsModalLoaded = true;
|
||||
|
||||
// Esperar un momento para que el DOM se actualice
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Ahora cargar los settings
|
||||
if (window.toggleSettingsModal) {
|
||||
await window.toggleSettingsModal(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading settings modal:', err);
|
||||
}
|
||||
} else {
|
||||
if (window.toggleSettingsModal) {
|
||||
await window.toggleSettingsModal(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setupDropdown() {
|
||||
const userAvatarBtn = document.querySelector(".user-avatar-btn")
|
||||
const navDropdown = document.getElementById("nav-dropdown")
|
||||
|
||||
209
docker/src/scripts/local-library.js
Normal file
209
docker/src/scripts/local-library.js
Normal file
@@ -0,0 +1,209 @@
|
||||
let activeFilter = 'all';
|
||||
let activeSort = 'az';
|
||||
let isLocalMode = false;
|
||||
let localEntries = [];
|
||||
|
||||
function toggleLibraryMode() {
|
||||
isLocalMode = !isLocalMode;
|
||||
|
||||
const btn = document.getElementById('library-mode-btn');
|
||||
const onlineContent = document.getElementById('online-content');
|
||||
const localContent = document.getElementById('local-content');
|
||||
const svg = btn.querySelector('svg');
|
||||
const label = btn.querySelector('span');
|
||||
|
||||
if (isLocalMode) {
|
||||
// LOCAL MODE
|
||||
btn.classList.add('active');
|
||||
onlineContent.classList.add('hidden');
|
||||
localContent.classList.remove('hidden');
|
||||
loadLocalEntries();
|
||||
|
||||
svg.innerHTML = `
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
`;
|
||||
} else {
|
||||
// ONLINE MODE
|
||||
btn.classList.remove('active');
|
||||
onlineContent.classList.remove('hidden');
|
||||
localContent.classList.add('hidden');
|
||||
|
||||
svg.innerHTML = `
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocalEntries() {
|
||||
const grid = document.getElementById('local-entries-grid');
|
||||
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(8);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/library/anime');
|
||||
const entries = await response.json();
|
||||
localEntries = entries;
|
||||
|
||||
if (entries.length === 0) {
|
||||
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No anime found in your local library. Click "Scan Library" to scan your folders.</p>';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Renderizar grid
|
||||
grid.innerHTML = entries.map(entry => {
|
||||
const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id;
|
||||
const cover = entry.metadata?.coverImage?.extraLarge || entry.metadata?.coverImage?.large || '/public/assets/placeholder.jpg';
|
||||
const score = entry.metadata?.averageScore || '--';
|
||||
const episodes = entry.metadata?.episodes || '??';
|
||||
|
||||
return `
|
||||
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
|
||||
<div class="card-img-wrap">
|
||||
<img src="${cover}" alt="${title}" loading="lazy">
|
||||
</div>
|
||||
<div class="local-card-info">
|
||||
<div class="local-card-title">${title}</div>
|
||||
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
|
||||
${score}% • ${episodes} Eps
|
||||
</p>
|
||||
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
|
||||
${entry.matched ? '● Linked' : '○ Unlinked'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Error loading local entries:', err);
|
||||
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local library. Make sure the backend is running.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function scanLocalLibrary() {
|
||||
const btnText = document.getElementById('scan-text');
|
||||
const originalText = btnText.innerText;
|
||||
btnText.innerText = "Scanning...";
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/library/scan?mode=incremental', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadLocalEntries();
|
||||
// Mostrar notificación de éxito si tienes sistema de notificaciones
|
||||
if (window.NotificationUtils) {
|
||||
NotificationUtils.show('Library scanned successfully!', 'success');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Scan failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Scan failed", err);
|
||||
alert("Failed to scan library. Check console for details.");
|
||||
|
||||
// Mostrar notificación de error si tienes sistema de notificaciones
|
||||
if (window.NotificationUtils) {
|
||||
NotificationUtils.show('Failed to scan library', 'error');
|
||||
}
|
||||
} finally {
|
||||
btnText.innerText = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function viewLocalEntry(anilistId) {
|
||||
if (!anilistId) {
|
||||
console.warn('Anime not linked');
|
||||
return;
|
||||
}
|
||||
window.location.href = `/anime/${anilistId}`;
|
||||
}
|
||||
|
||||
function renderLocalEntries(entries) {
|
||||
const grid = document.getElementById('local-entries-grid');
|
||||
|
||||
grid.innerHTML = entries.map(entry => {
|
||||
const title = entry.metadata?.title?.romaji
|
||||
|| entry.metadata?.title?.english
|
||||
|| entry.id;
|
||||
|
||||
const cover =
|
||||
entry.metadata?.coverImage?.extraLarge
|
||||
|| entry.metadata?.coverImage?.large
|
||||
|| '/public/assets/placeholder.jpg';
|
||||
|
||||
const score = entry.metadata?.averageScore || '--';
|
||||
const episodes = entry.metadata?.episodes || '??';
|
||||
|
||||
return `
|
||||
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
|
||||
<div class="card-img-wrap">
|
||||
<img src="${cover}" alt="${title}" loading="lazy">
|
||||
</div>
|
||||
<div class="local-card-info">
|
||||
<div class="local-card-title">${title}</div>
|
||||
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
|
||||
${score}% • ${episodes} Eps
|
||||
</p>
|
||||
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
|
||||
${entry.matched ? '● Linked' : '○ Unlinked'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function applyLocalFilters() {
|
||||
let filtered = [...localEntries];
|
||||
|
||||
if (activeFilter === 'linked') {
|
||||
filtered = filtered.filter(e => e.matched);
|
||||
}
|
||||
|
||||
if (activeFilter === 'unlinked') {
|
||||
filtered = filtered.filter(e => !e.matched);
|
||||
}
|
||||
|
||||
if (activeSort === 'az') {
|
||||
filtered.sort((a, b) =>
|
||||
(a.metadata?.title?.romaji || a.id)
|
||||
.localeCompare(b.metadata?.title?.romaji || b.id)
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSort === 'za') {
|
||||
filtered.sort((a, b) =>
|
||||
(b.metadata?.title?.romaji || b.id)
|
||||
.localeCompare(a.metadata?.title?.romaji || a.id)
|
||||
);
|
||||
}
|
||||
|
||||
renderLocalEntries(filtered);
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.filter-btn');
|
||||
if (!btn) return;
|
||||
|
||||
if (btn.dataset.filter) {
|
||||
activeFilter = btn.dataset.filter;
|
||||
}
|
||||
|
||||
if (btn.dataset.sort) {
|
||||
activeSort = btn.dataset.sort;
|
||||
}
|
||||
|
||||
btn
|
||||
.closest('.local-filters')
|
||||
.querySelectorAll('.filter-btn')
|
||||
.forEach(b => b.classList.remove('active'));
|
||||
|
||||
btn.classList.add('active');
|
||||
|
||||
applyLocalFilters();
|
||||
});
|
||||
218
docker/src/scripts/settings.js
Normal file
218
docker/src/scripts/settings.js
Normal file
@@ -0,0 +1,218 @@
|
||||
const API_BASE = '/api/config';
|
||||
let currentConfig = {};
|
||||
let activeSection = '';
|
||||
let modal, navContainer, formContent, form;
|
||||
|
||||
window.toggleSettingsModal = async (forceClose = false) => {
|
||||
modal = document.getElementById('settings-modal');
|
||||
navContainer = document.getElementById('config-nav');
|
||||
formContent = document.getElementById('config-section-content');
|
||||
form = document.getElementById('config-form');
|
||||
|
||||
if (!modal) {
|
||||
console.error('Modal not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (forceClose) {
|
||||
modal.classList.add('hidden');
|
||||
} else {
|
||||
const isHidden = modal.classList.contains('hidden');
|
||||
|
||||
if (isHidden) {
|
||||
// Abrir modal
|
||||
modal.classList.remove('hidden');
|
||||
await loadSettings();
|
||||
} else {
|
||||
// Cerrar modal
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function loadSettings() {
|
||||
if (!formContent) {
|
||||
console.error('Form content not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mostrar loading
|
||||
formContent.innerHTML = `
|
||||
<div class="skeleton-loader">
|
||||
<div class="skeleton title-skeleton"></div>
|
||||
<div class="skeleton text-skeleton"></div>
|
||||
<div class="skeleton text-skeleton"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const res = await fetch(API_BASE);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
currentConfig = data;
|
||||
renderNav();
|
||||
|
||||
// Seleccionar la primera sección si no hay ninguna activa
|
||||
if (!activeSection || !currentConfig[activeSection]) {
|
||||
activeSection = Object.keys(currentConfig)[0];
|
||||
}
|
||||
|
||||
switchSection(activeSection);
|
||||
} catch (err) {
|
||||
console.error('Error loading settings:', err);
|
||||
formContent.innerHTML = `
|
||||
<div style="padding: 2rem; text-align: center;">
|
||||
<p style="color: var(--color-danger); margin-bottom: 1rem;">Failed to load settings</p>
|
||||
<p style="color: var(--color-text-muted); font-size: 0.9rem;">${err.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
if (!navContainer) return;
|
||||
|
||||
navContainer.innerHTML = '';
|
||||
Object.keys(currentConfig).forEach(section => {
|
||||
const btn = document.createElement('div');
|
||||
btn.className = `nav-item ${section === activeSection ? 'active' : ''}`;
|
||||
btn.textContent = section;
|
||||
btn.onclick = () => switchSection(section);
|
||||
navContainer.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function switchSection(section) {
|
||||
if (!currentConfig[section]) return;
|
||||
|
||||
activeSection = section;
|
||||
renderNav();
|
||||
|
||||
const sectionData = currentConfig[section];
|
||||
|
||||
formContent.innerHTML = `
|
||||
<h2 class="section-title" style="margin-bottom: 2rem; text-transform: capitalize;">
|
||||
${section.replace(/_/g, ' ')}
|
||||
</h2>
|
||||
`;
|
||||
|
||||
Object.entries(sectionData).forEach(([key, value]) => {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'config-group';
|
||||
|
||||
const isBool = typeof value === 'boolean';
|
||||
const inputId = `input-${section}-${key}`;
|
||||
const label = key.replace(/_/g, ' ');
|
||||
|
||||
if (isBool) {
|
||||
group.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="${inputId}" name="${key}" ${value ? 'checked' : ''}>
|
||||
<label for="${inputId}" style="margin: 0; cursor: pointer;">${label}</label>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
group.innerHTML = `
|
||||
<label for="${inputId}">${label}</label>
|
||||
<input class="config-input" id="${inputId}" name="${key}"
|
||||
type="${typeof value === 'number' ? 'number' : 'text'}"
|
||||
value="${value}">
|
||||
`;
|
||||
}
|
||||
|
||||
formContent.appendChild(group);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup form submit handler
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Usar delegación de eventos ya que el form se carga dinámicamente
|
||||
document.addEventListener('submit', async (e) => {
|
||||
if (e.target.id === 'config-form') {
|
||||
e.preventDefault();
|
||||
await saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function saveSettings() {
|
||||
if (!form || !activeSection) return;
|
||||
|
||||
const updatedData = {};
|
||||
|
||||
Object.keys(currentConfig[activeSection]).forEach(key => {
|
||||
const input = form.elements[key];
|
||||
if (!input) return;
|
||||
|
||||
if (input.type === 'checkbox') {
|
||||
updatedData[key] = input.checked;
|
||||
} else if (input.type === 'number') {
|
||||
updatedData[key] = Number(input.value);
|
||||
} else {
|
||||
updatedData[key] = input.value;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/${activeSection}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
currentConfig[activeSection] = updatedData;
|
||||
|
||||
// Mostrar notificación de éxito
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: var(--color-success, #10b981);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 10000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
notification.textContent = 'Settings saved successfully!';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideOut 0.3s ease-out';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error('Failed to save settings');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
alert('Error saving settings: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir estilos para las animaciones (solo si no existen)
|
||||
if (!document.getElementById('settings-animations')) {
|
||||
const animationStyles = document.createElement('style');
|
||||
animationStyles.id = 'settings-animations';
|
||||
animationStyles.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(400px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(400px); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(animationStyles);
|
||||
}
|
||||
71
docker/src/shared/config.js
Normal file
71
docker/src/shared/config.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
const BASE_DIR = path.join(os.homedir(), 'WaifuBoards');
|
||||
const CONFIG_PATH = path.join(BASE_DIR, 'config.yaml');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
library: {
|
||||
anime: null,
|
||||
manga: null,
|
||||
novels: null
|
||||
}
|
||||
};
|
||||
|
||||
function ensureConfigFile() {
|
||||
if (!fs.existsSync(BASE_DIR)) {
|
||||
fs.mkdirSync(BASE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
fs.writeFileSync(
|
||||
CONFIG_PATH,
|
||||
yaml.dump(DEFAULT_CONFIG),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
ensureConfigFile();
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
return yaml.load(raw) || DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
export function setConfig(partialConfig) {
|
||||
ensureConfigFile();
|
||||
|
||||
const current = getConfig();
|
||||
const next = deepMerge(current, partialConfig);
|
||||
|
||||
fs.writeFileSync(
|
||||
CONFIG_PATH,
|
||||
yaml.dump(next),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function deepMerge(target, source) {
|
||||
for (const key in source) {
|
||||
if (
|
||||
source[key] &&
|
||||
typeof source[key] === 'object' &&
|
||||
!Array.isArray(source[key])
|
||||
) {
|
||||
target[key] = deepMerge(target[key] || {}, source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureConfigFile,
|
||||
getConfig,
|
||||
setConfig,
|
||||
};
|
||||
@@ -2,7 +2,7 @@ const sqlite3 = require('sqlite3').verbose();
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB} = require('./schemas');
|
||||
const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB, ensureLocalLibrarySchema } = require('./schemas');
|
||||
|
||||
const databases = new Map();
|
||||
|
||||
@@ -10,7 +10,8 @@ const DEFAULT_PATHS = {
|
||||
anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'),
|
||||
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
|
||||
cache: path.join(os.homedir(), "WaifuBoards", "cache.db"),
|
||||
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db")
|
||||
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db"),
|
||||
local_library: path.join(os.homedir(), "WaifuBoards", "local_library.db")
|
||||
};
|
||||
|
||||
function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
|
||||
@@ -49,6 +50,11 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
|
||||
|
||||
databases.set(name, db);
|
||||
|
||||
if (name === "local_library") {
|
||||
ensureLocalLibrarySchema(db)
|
||||
.catch(err => console.error("Error creating local library schema:", err));
|
||||
}
|
||||
|
||||
if (name === "anilist") {
|
||||
ensureAnilistSchema(db)
|
||||
.then(() => ensureExtensionsTable(db))
|
||||
|
||||
@@ -2,6 +2,54 @@ const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
async function ensureLocalLibrarySchema(db) {
|
||||
await run(db, `
|
||||
CREATE TABLE IF NOT EXISTS local_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
folder_name TEXT NOT NULL,
|
||||
matched_id INTEGER,
|
||||
matched_source TEXT,
|
||||
last_scan INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await run(db, `
|
||||
CREATE TABLE IF NOT EXISTS local_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
entry_id TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
unit_number INTEGER,
|
||||
FOREIGN KEY (entry_id) REFERENCES local_entries(id)
|
||||
)
|
||||
`);
|
||||
|
||||
await run(db, `
|
||||
CREATE INDEX IF NOT EXISTS idx_local_entries_type
|
||||
ON local_entries(type)
|
||||
`);
|
||||
|
||||
await run(db, `
|
||||
CREATE INDEX IF NOT EXISTS idx_local_entries_matched
|
||||
ON local_entries(matched_id)
|
||||
`);
|
||||
|
||||
await run(db, `
|
||||
CREATE INDEX IF NOT EXISTS idx_local_files_entry
|
||||
ON local_files(entry_id)
|
||||
`);
|
||||
}
|
||||
|
||||
function run(db, sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, err => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureUserDataDB(dbPath) {
|
||||
const dir = path.dirname(dbPath);
|
||||
|
||||
@@ -230,5 +278,6 @@ module.exports = {
|
||||
ensureAnilistSchema,
|
||||
ensureExtensionsTable,
|
||||
ensureCacheTable,
|
||||
ensureFavoritesDB
|
||||
ensureFavoritesDB,
|
||||
ensureLocalLibrarySchema
|
||||
};
|
||||
@@ -77,6 +77,16 @@ async function viewsRoutes(fastify: FastifyInstance) {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'read.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
|
||||
fastify.setNotFoundHandler((req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', '404.html'));
|
||||
|
||||
|
||||
reply
|
||||
.code(404)
|
||||
.type('text/html')
|
||||
.send(stream)
|
||||
})
|
||||
}
|
||||
|
||||
export default viewsRoutes;
|
||||
139
docker/views/404.html
Normal file
139
docker/views/404.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 - WaifuBoard</title>
|
||||
<link rel="stylesheet" href="/views/css/globals.css">
|
||||
<link rel="stylesheet" href="/views/css/components/navbar.css">
|
||||
<link rel="stylesheet" href="/views/css/components/titlebar.css">
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
|
||||
<script src="/src/scripts/titlebar.js"></script>
|
||||
|
||||
<style>
|
||||
.error-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--spacing-md) 0 var(--spacing-xl);
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="titlebar">
|
||||
<div class="title-left">
|
||||
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
|
||||
<span class="app-title">WaifuBoard</span>
|
||||
</div>
|
||||
<div class="title-right">
|
||||
<button class="min">−</button>
|
||||
<button class="max">🗖</button>
|
||||
<button class="close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="navbar" id="navbar">
|
||||
<a href="#" class="nav-brand">
|
||||
<div class="brand-icon">
|
||||
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
|
||||
</div>
|
||||
WaifuBoard
|
||||
</a>
|
||||
|
||||
<div class="nav-center">
|
||||
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
|
||||
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
||||
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
||||
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
||||
<button class="nav-button" onclick="window.location.href='/my-list'">My List</button>
|
||||
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<div class="search-wrapper" style="visibility: hidden;">
|
||||
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
|
||||
<div class="search-results" id="search-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="nav-user" id="nav-user" style="display:none;">
|
||||
<div class="user-avatar-btn">
|
||||
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
|
||||
<div class="online-indicator"></div>
|
||||
</div>
|
||||
|
||||
<div class="nav-dropdown" id="nav-dropdown">
|
||||
<div class="dropdown-header">
|
||||
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
|
||||
<div class="dropdown-user-info">
|
||||
<div class="dropdown-username" id="nav-username"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/my-list" class="dropdown-item">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
<span>My List</span>
|
||||
</a>
|
||||
<button class="dropdown-item logout-item" id="nav-logout">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="error-container">
|
||||
<div>
|
||||
<h1 class="error-code">404</h1>
|
||||
<p class="error-message">
|
||||
This page doesn’t exist.
|
||||
</p>
|
||||
|
||||
<div class="error-actions">
|
||||
<button class="btn-primary" onclick="location.href='/'">Home</button>
|
||||
<button class="btn-blur" onclick="history.back()">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/src/scripts/utils/auth-utils.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -219,6 +219,7 @@
|
||||
id="extension-pill"
|
||||
style="display: none; background: #8b5cf6"
|
||||
></div>
|
||||
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
|
||||
<div class="pill score" id="score">--% Score</div>
|
||||
<div class="pill" id="year">----</div>
|
||||
<div class="pill" id="genres">Action</div>
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
<link rel="stylesheet" href="/views/css/components/hero.css">
|
||||
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
|
||||
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
|
||||
<link rel="stylesheet" href="/views/css/components/local-library.css">
|
||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||
</head>
|
||||
<body >
|
||||
<body>
|
||||
<nav class="navbar" id="navbar">
|
||||
<a href="#" class="nav-brand">
|
||||
<div class="brand-icon">
|
||||
@@ -53,6 +54,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="dropdown-item" id="nav-settings">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82V22a2 2 0 1 1-4 0v-.18a1.65 1.65 0 0 0-.33-1.82 1.65 1.65 0 0 0-1-.6 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33H2a2 2 0 1 1 0-4h.18a1.65 1.65 0 0 0 1.82-.33 1.65 1.65 0 0 0 .6-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6c.37 0 .72-.14 1-.6A1.65 1.65 0 0 0 10.33 2.18V2a2 2 0 1 1 4 0v.18a1.65 1.65 0 0 0 .33 1.82c.28.46.63.6 1 .6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c0 .37.14.72.6 1 .46.28.6.63.6 1z"/>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
|
||||
<a href="/my-list" class="dropdown-item">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
@@ -108,8 +117,102 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Online Mode Content -->
|
||||
<main id="online-content">
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Continue watching</div>
|
||||
</div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('my-status', -1)">‹</button>
|
||||
<div class="carousel" id="my-status">
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
</div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('my-status', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header"><div class="section-title">Trending This Season</div></div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('trending', -1)">‹</button>
|
||||
<div class="carousel" id="trending">
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
</div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('trending', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header"><div class="section-title">Top Airing Now</div></div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('top-airing', -1)">‹</button>
|
||||
<div class="carousel" id="top-airing">
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
</div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('top-airing', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Local Library Mode Content -->
|
||||
<main id="local-content" class="hidden">
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Local Anime Library</div>
|
||||
<button class="btn-secondary" onclick="scanLocalLibrary()">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M21 12a9 9 0 1 1-9-9"/>
|
||||
<path d="M21 3v6h-6"/>
|
||||
</svg>
|
||||
<span id="scan-text">Scan Library</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="local-filters">
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="watching">Watching</button>
|
||||
<button class="filter-btn" data-filter="completed">Completed</button>
|
||||
<button class="filter-btn" data-filter="unwatched">Unwatched</button>
|
||||
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn" data-sort="az">A–Z</button>
|
||||
<button class="filter-btn" data-sort="recent">Recent</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="local-library-grid" id="local-entries-grid">
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal-overlay" id="add-list-modal">
|
||||
<div class="modal-content modal-list">
|
||||
<button class="modal-close" onclick="closeAddToListModal()">✕</button>
|
||||
@@ -117,11 +220,10 @@
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="modal-fields-grid">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<select id="entry-status" class="form-input">
|
||||
<option value="WATCHING">Watching/Reading</option>
|
||||
<option value="WATCHING"><Watchi></Watchi>ng/Reading</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="PLANNING">Planning</option>
|
||||
<option value="PAUSED">Paused</option>
|
||||
@@ -167,7 +269,6 @@
|
||||
<input type="checkbox" id="entry-is-private" class="form-checkbox">
|
||||
<label for="entry-is-private">Mark as Private</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,63 +279,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main>
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Continue watching</div>
|
||||
</div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('my-status', -1)">‹</button>
|
||||
<div class="carousel" id="my-status">
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
</div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('my-status', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header"><div class="section-title">Trending This Season</div></div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('trending', -1)">‹</button>
|
||||
<div class="carousel" id="trending">
|
||||
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
</div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('trending', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header"><div class="section-title">Top Airing Now</div></div>
|
||||
<div class="carousel-wrapper">
|
||||
<button class="scroll-btn left" onclick="scrollCarousel('top-airing', -1)">‹</button>
|
||||
<div class="carousel" id="top-airing">
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||
</div>
|
||||
<button class="scroll-btn right" onclick="scrollCarousel('top-airing', 1)">›</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="updateToast" class="hidden">
|
||||
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
|
||||
|
||||
<a
|
||||
id="downloadButton"
|
||||
href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases"
|
||||
target="_blank"
|
||||
>
|
||||
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">
|
||||
Click To Download
|
||||
</a>
|
||||
</div>
|
||||
@@ -245,7 +293,9 @@
|
||||
<script src="/src/scripts/utils/continue-watching-manager.js"></script>
|
||||
<script src="/src/scripts/utils/youtube-player-utils.js"></script>
|
||||
<script src="/src/scripts/anime/animes.js"></script>
|
||||
<script src="/src/scripts/local-library.js"></script>
|
||||
<script src="/src/scripts/updateNotifier.js"></script>
|
||||
<script src="/src/scripts/auth-guard.js"></script>
|
||||
<script src="/src/scripts/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
62
docker/views/components/navbar.html
Normal file
62
docker/views/components/navbar.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<nav class="navbar" id="navbar">
|
||||
<a href="#" class="nav-brand">
|
||||
<div class="brand-icon">
|
||||
<img src="/public/assets/waifuboards.ico" alt="WF Logo">
|
||||
</div>
|
||||
WaifuBoard
|
||||
</a>
|
||||
|
||||
<nav class="navbar">
|
||||
<div class="nav-center">
|
||||
<a href="/anime" class="nav-button {{anime}}">Anime</a>
|
||||
<a href="/books" class="nav-button {{books}}">Books</a>
|
||||
<a href="/gallery" class="nav-button {{gallery}}">Gallery</a>
|
||||
<a href="/schedule" class="nav-button {{schedule}}">Schedule</a>
|
||||
<a href="/my-list" class="nav-button {{myList}}">My List</a>
|
||||
<a href="/marketplace" class="nav-button {{marketplace}}">Marketplace</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<div class="search-wrapper {{hideSearch}}">
|
||||
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text" class="search-input" id="search-input" placeholder="Search anime..." autocomplete="off">
|
||||
<div class="search-results" id="search-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="nav-user" id="nav-user" style="display:none;">
|
||||
<div class="user-avatar-btn">
|
||||
<img id="nav-avatar" src="/public/assets/waifuboards.ico" alt="avatar">
|
||||
<div class="online-indicator"></div>
|
||||
</div>
|
||||
|
||||
<div class="nav-dropdown" id="nav-dropdown">
|
||||
<div class="dropdown-header">
|
||||
<img id="dropdown-avatar" src="/public/assets/waifuboards.ico" alt="avatar" class="dropdown-avatar">
|
||||
<div class="dropdown-user-info">
|
||||
<div class="dropdown-username" id="nav-username"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/my-list" class="dropdown-item">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
<span>My List</span>
|
||||
</a>
|
||||
<button class="dropdown-item logout-item" id="nav-logout">
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
278
docker/views/components/settings-modal.html
Normal file
278
docker/views/components/settings-modal.html
Normal file
@@ -0,0 +1,278 @@
|
||||
<div id="settings-modal" class="modal hidden" onclick="if(event.target === this) window.toggleSettingsModal(true)">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
|
||||
<aside class="modal-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 class="sidebar-title">Settings</h2>
|
||||
</div>
|
||||
<nav id="config-nav" class="nav-list">
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button onclick="window.toggleSettingsModal(true)" class="btn-exit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="modal-main">
|
||||
<form id="config-form" class="config-wrapper">
|
||||
<div id="config-section-content" class="section-container">
|
||||
<div class="skeleton-loader">
|
||||
<div class="skeleton title-skeleton"></div>
|
||||
<div class="skeleton field-skeleton"></div>
|
||||
<div class="skeleton field-skeleton"></div>
|
||||
<div class="skeleton field-skeleton"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer-sticky">
|
||||
<p class="footer-hint">Changes are applied immediately after saving.</p>
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* --- AMOLED THEME VARIABLES --- */
|
||||
:root {
|
||||
--amoled-black: #000000;
|
||||
--amoled-surface: #080808;
|
||||
--amoled-field: #0e0e0e;
|
||||
--amoled-border: rgba(255, 255, 255, 0.08);
|
||||
--accent-purple: #8b5cf6;
|
||||
--accent-glow: rgba(139, 92, 246, 0.15);
|
||||
--text-main: #ffffff;
|
||||
--text-dim: #a1a1aa;
|
||||
}
|
||||
|
||||
/* --- MODAL BASE --- */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal.hidden { display: none !important; }
|
||||
|
||||
.modal-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row; /* Horizontal layout */
|
||||
width: 95%;
|
||||
max-width: 1200px; /* Increased size */
|
||||
height: 85vh;
|
||||
background: var(--amoled-black);
|
||||
border: var(--amoled-border);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--amoled-border), 0 24px 60px rgba(0,0,0,0.8);
|
||||
animation: modalScaleUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* --- SIDEBAR --- */
|
||||
.modal-sidebar {
|
||||
width: 280px;
|
||||
background: var(--amoled-surface);
|
||||
border-right: var(--amoled-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 2.5rem;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.nav-list { flex: 1; }
|
||||
|
||||
.nav-item {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-glow);
|
||||
color: var(--accent-purple);
|
||||
box-shadow: inset 3px 0 0 var(--accent-purple);
|
||||
}
|
||||
|
||||
/* --- MAIN CONTENT & DYNAMIC INPUTS --- */
|
||||
.modal-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--amoled-black);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section-container {
|
||||
flex: 1;
|
||||
padding: 3.5rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #222 transparent;
|
||||
}
|
||||
|
||||
/* Styles for the injected section content */
|
||||
.config-group {
|
||||
margin-bottom: 2.5rem;
|
||||
animation: fadeInSection 0.4s ease-out;
|
||||
}
|
||||
|
||||
.config-group label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-purple);
|
||||
margin-bottom: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.config-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.2rem;
|
||||
background: var(--amoled-field);
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.config-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-purple);
|
||||
background: #121212;
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* --- FOOTER --- */
|
||||
.modal-footer-sticky {
|
||||
padding: 1.5rem 3.5rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: var(--amoled-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* --- BUTTONS --- */
|
||||
.btn-primary {
|
||||
padding: 0.8rem 2.2rem;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border: none;
|
||||
border-radius: 100px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.btn-exit {
|
||||
background: #111;
|
||||
border: 1px solid #222;
|
||||
color: #ef4444;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* --- ANIMATIONS & SKELETON --- */
|
||||
@keyframes modalScaleUp {
|
||||
from { opacity: 0; transform: scale(0.97) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInSection {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.skeleton-loader { display: flex; flex-direction: column; gap: 2rem; }
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #080808 25%, #121212 50%, #080808 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.title-skeleton { height: 35px; width: 40%; }
|
||||
.field-skeleton { height: 55px; width: 100%; }
|
||||
|
||||
/* Responsive Mobile View */
|
||||
@media (max-width: 850px) {
|
||||
.modal-content { flex-direction: column; height: 95vh; width: 100vw; border-radius: 0; }
|
||||
.modal-sidebar { width: 100%; height: auto; border-right: none; border-bottom: var(--amoled-border); padding: 1rem; }
|
||||
.sidebar-title { margin-bottom: 1rem; font-size: 1.2rem; }
|
||||
.section-container { padding: 2rem; }
|
||||
.modal-footer-sticky { padding: 1.5rem 2rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -34,7 +34,7 @@
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.poster-card img {
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
.info-grid {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
@@ -53,36 +53,16 @@
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.info-item h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.info-item span {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.info-item h4 { margin: 0 0 0.25rem 0; font-size: 0.85rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.info-item span { font-weight: 600; font-size: 1rem; color: var(--color-text-primary); }
|
||||
|
||||
.character-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.character-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.char-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.character-item { display: flex; align-items: center; gap: 0.75rem; font-size: 0.95rem; }
|
||||
.char-dot { width: 6px; height: 6px; background: var(--color-primary); border-radius: 50%; }
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
@@ -99,7 +79,7 @@
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
margin: 0 0 1.5rem 0;
|
||||
text-shadow: 0 4px 30px rgba(0, 0, 0, 0.8);
|
||||
text-shadow: 0 4px 30px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
@@ -112,18 +92,14 @@
|
||||
|
||||
.pill {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255,255,255,0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.pill.score {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
.pill.score { background: rgba(34, 197, 94, 0.2); color: #4ade80; border-color: rgba(34, 197, 94, 0.2); }
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
@@ -143,9 +119,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.btn-watch:hover {
|
||||
@@ -161,7 +135,7 @@
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
@@ -179,30 +153,17 @@
|
||||
line-height: 1.8;
|
||||
color: #e4e4e7;
|
||||
max-width: 900px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: rgba(255,255,255,0.03);
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.episodes-section {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.section-title::before {
|
||||
content: "";
|
||||
width: 4px;
|
||||
height: 28px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.section-title { font-size: 1.8rem; font-weight: 800; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.8rem; }
|
||||
.section-title::before { content: ''; width: 4px; height: 28px; background: var(--color-primary); border-radius: 2px; }
|
||||
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
@@ -212,14 +173,14 @@
|
||||
|
||||
.episode-btn {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 1.25rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.episode-btn:hover {
|
||||
@@ -230,14 +191,8 @@
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(60px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from { opacity: 0; transform: translateY(60px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@@ -246,24 +201,11 @@
|
||||
margin-top: -100px;
|
||||
padding: 0 1.5rem 4rem 1.5rem;
|
||||
}
|
||||
.poster-card {
|
||||
width: 220px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.main-content {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
.anime-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.meta-row {
|
||||
justify-content: center;
|
||||
}
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
.poster-card { width: 220px; margin: 0 auto; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
|
||||
.main-content { text-align: center; align-items: center; }
|
||||
.anime-title { font-size: 2.5rem; }
|
||||
.meta-row { justify-content: center; }
|
||||
.sidebar { display: none; }
|
||||
}
|
||||
|
||||
.read-more-btn {
|
||||
@@ -279,9 +221,7 @@
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.read-more-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.read-more-btn:hover { text-decoration: underline; }
|
||||
|
||||
.episodes-header-row {
|
||||
display: flex;
|
||||
@@ -321,10 +261,7 @@
|
||||
}
|
||||
|
||||
.episode-search-input::-webkit-outer-spin-button,
|
||||
.episode-search-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.episode-search-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
@@ -358,118 +295,3 @@
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-container,
|
||||
.section,
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: -50px;
|
||||
padding: 1rem 1.25rem 4rem 1.25rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.poster-card {
|
||||
width: 180px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
order: 2;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.anime-title {
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-watch,
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.description-box {
|
||||
margin-top: 2rem;
|
||||
padding: 1.25rem;
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.episodes-section {
|
||||
margin-top: 3rem;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.episodes-header-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.episode-search-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.episode-search-input {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.episodes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.episode-btn {
|
||||
padding: 0.8rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
132
docker/views/css/components/local-library.css
Normal file
132
docker/views/css/components/local-library.css
Normal file
@@ -0,0 +1,132 @@
|
||||
.library-mode-btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.library-mode-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.library-mode-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.library-mode-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.local-library-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.local-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.local-card:hover {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.local-card-info {
|
||||
padding: 0.8rem 0;
|
||||
}
|
||||
|
||||
.local-card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.match-status {
|
||||
font-size: 0.85rem;
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.status-linked {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-unlinked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.hero-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.library-mode-btn.icon-only {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
|
||||
.hero-mode-switch .library-mode-btn {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.local-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
Reference in New Issue
Block a user