init
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.idea
|
||||
.vscode
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
DOckerfile
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
node_modules
|
||||
archive
|
||||
55
.github/workflows/build.yaml
vendored
Normal file
55
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Build Website
|
||||
run-name: Build Website
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build NPM Project
|
||||
runs-on: ubuntu-latest
|
||||
container: node
|
||||
steps:
|
||||
- name: Clone Repository
|
||||
uses: ztimson/actions/clone@develop
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Build Project
|
||||
run: npm run build
|
||||
|
||||
tag:
|
||||
name: Tag Version
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
container: node
|
||||
steps:
|
||||
- name: Clone Repository
|
||||
uses: ztimson/actions/clone@develop
|
||||
|
||||
- name: Get Version Number
|
||||
run: echo "VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')" >> $GITHUB_ENV
|
||||
|
||||
- name: Tag Version
|
||||
uses: ztimson/actions/tag@develop
|
||||
with:
|
||||
tag: ${{env.VERSION}}
|
||||
|
||||
publish:
|
||||
name: Build & Push Dockerfile
|
||||
needs: build
|
||||
uses: ztimson/actions/.github/workflows/docker.yaml@develop
|
||||
with:
|
||||
name: ztimson/kiwixm
|
||||
repository: ${{github.server_url}}/${{github.repository}}.git
|
||||
pass: ${{secrets.DEPLOY_TOKEN}}
|
||||
|
||||
publish-docker:
|
||||
name: Build & Push Dockerfile
|
||||
needs: build
|
||||
uses: ztimson/actions/.github/workflows/docker.yaml@develop
|
||||
with:
|
||||
name: ztimson/kiwixm
|
||||
repository: hub.docker.com
|
||||
pass: ${{secrets.DOCKER_TOKEN}}
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.idea
|
||||
.vscode
|
||||
node_modules
|
||||
dist
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM public.ecr.aws/docker/library/node
|
||||
|
||||
# Environment
|
||||
ENV PORT=3000 \
|
||||
KIWIX_MANAGE=/usr/bin/kiwix-manage \
|
||||
KIWIX_SERVE=/usr/bin/kiwix-serve \
|
||||
ARCHIVE_DIR=/data/archive \
|
||||
ZIM_DIR=/data/zim
|
||||
|
||||
# Install dependencies
|
||||
RUN apt update && apt upgrade -y && \
|
||||
apt install -y --no-install-recommends \
|
||||
curl docker.io kiwix-tools unzip zim-tools && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Setup app
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN mkdir -p $ARCHIVE_DIR && mkdir -p $ZIM_DIR && npm i
|
||||
|
||||
# Startup
|
||||
EXPOSE $PORT
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -f http://localhost:$PORT || exit 1
|
||||
CMD ["npm", "run", "start:prod"]
|
||||
104
README.md
Normal file
104
README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
<!-- Header -->
|
||||
<div id="top" align="center">
|
||||
<br />
|
||||
|
||||
<!-- Logo -->
|
||||
<img src="favicon.png" alt="Logo" width="200" height="200">
|
||||
|
||||
<!-- Title -->
|
||||
### Kiwix Manager
|
||||
|
||||
<!-- Description -->
|
||||
A web-based Kiwix management tool to create & upload zims as well as manage non zim files
|
||||
|
||||
<!-- Repo badges -->
|
||||
[](https://git.zakscode.com/ztimson/kiwixm/tags)
|
||||
[](https://git.zakscode.com/ztimson/kiwixm/pulls)
|
||||
[](https://git.zakscode.com/ztimson/kiwixm/issues)
|
||||
|
||||
<!-- Links -->
|
||||
|
||||
---
|
||||
<div>
|
||||
<a href="https://git.zakscode.com/ztimson/kiwixm/wiki" target="_blank">Documentation</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/kiwixm/releases" target="_blank">Release Notes</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/kiwixm/issues/new?template=.github%2fissue_template%2fbug.md" target="_blank">Report a Bug</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/kiwixm/issues/new?template=.github%2fissue_template%2fenhancement.md" target="_blank">Request a Feature</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
</div>
|
||||
|
||||
## Table of Contents
|
||||
- [Kiwix Manager](#top)
|
||||
- [About](#about)
|
||||
- [Features](#features)
|
||||
- [Built With](#built-with)
|
||||
- [Setup](#setup)
|
||||
- [Production](#production)
|
||||
- [Development](#development)
|
||||
- [License](#license)
|
||||
|
||||
## About
|
||||
|
||||
Kiwix Manager is a comprehensive web-based management interface for Kiwix servers that extends beyond traditional ZIM file management. It provides both a powerful Kiwix library management system and an integrated file browser for non-ZIM content, making it a complete solution for offline content libraries.
|
||||
|
||||
### Features
|
||||
|
||||
- **Non-ZIM library browser + uploader integrated into Kiwix** - Browse and upload any file type through a custom file browser that works alongside your Kiwix content
|
||||
- **Kiwix management interface for updating library content** - Full administrative control over your Kiwix server and ZIM library
|
||||
- **Upload ZIMs from computer or download them directly from Kiwix library** - Flexible ZIM file management with local uploads and remote downloads
|
||||
- **Create ZIMs from URLs (zimmit)** - Convert websites directly to ZIM files with customizable scraping options
|
||||
- **Create ZIMs from zips / files** - Generate ZIM archives from your existing file collections
|
||||
|
||||
### Built With
|
||||
[](https://docker.com/)
|
||||
[](https://expressjs.com/)
|
||||
[](https://javascript.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://tailwindcss.com/)
|
||||
|
||||
## Setup
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<h3 id="production" style="display: inline">
|
||||
Production
|
||||
</h3>
|
||||
</summary>
|
||||
|
||||
#### Prerequisites
|
||||
- [Docker](https://docs.docker.com/install/)
|
||||
|
||||
#### Instructions
|
||||
1. Run the docker image: `docker run -p 3000:3000 -v /path/to/zim/files:/data/zim -v /path/to/archive/files:/data/archive git.zakscode.com/ztimson/kiwixm:latest`
|
||||
2. Open [http://localhost:3000](http://localhost:3000)
|
||||
3. Access the admin panel at [http://localhost:3000/admin](http://localhost:3000/admin)
|
||||
4. Browse non-ZIM files at [http://localhost:3000/archive](http://localhost:3000/archive)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<h3 id="development" style="display: inline">
|
||||
Development
|
||||
</h3>
|
||||
</summary>
|
||||
|
||||
#### Prerequisites
|
||||
- [Node.js](https://nodejs.org/en/download)
|
||||
- [Kiwix Tools](https://kiwix.org/en/downloads/kiwix-tools/) (for ZIM file management)
|
||||
|
||||
#### Instructions
|
||||
1. Clone the repository: `git clone https://git.zakscode.com/ztimson/kiwixm.git`
|
||||
2. Navigate to the project directory: `cd kiwixm`
|
||||
3. Install the dependencies: `npm install`
|
||||
4. Create the required directories: `mkdir -p data/zim data/archive`
|
||||
5. Start the server: `npm run start`
|
||||
6. Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
Copyright © 2023 Zakary Timson | All Rights Reserved | Available under MIT Licensing
|
||||
|
||||
See the [license](./LICENSE) for more information.
|
||||
13
archive/index.html
Normal file
13
archive/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Kiwix Archive</title>
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<a href="/archive"><h1>Kiwix Archive</h1></a>
|
||||
<script>
|
||||
location.href = '/archive';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
archive/logo.png
Normal file
BIN
archive/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/archive.zim
Normal file
BIN
assets/archive.zim
Normal file
Binary file not shown.
BIN
favicon.png
Normal file
BIN
favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "kiwixm",
|
||||
"version": "1.0.0",
|
||||
"description": "Kiwix Library Manager",
|
||||
"main": "src/main.mjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/main.mjs",
|
||||
"start:prod": "mv assets/* $ZIM_DIR/ && npm run start",
|
||||
"archive-zim": "zimwriterfs --welcome=index.html --illustration=logo.png --language=eng --name=Archive --title=Archive --description=\"Your personal space for saving and browsing uploaded files\" --creator=ZaksCode --publisher=ZaksCode ./archive assets/archive.zim"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Zak Timson",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.2",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2"
|
||||
}
|
||||
}
|
||||
139
src/controllers/admin.mjs
Normal file
139
src/controllers/admin.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import {deleteFile, downloadFile, ensureDirectoryExists, getZimFiles, upload} from '../services/files.mjs';
|
||||
import {restartKiwix} from '../services/kiwix.mjs';
|
||||
import {environment} from '../services/environment.mjs';
|
||||
import {createZimFromDirectory, createZimFromUrl} from '../services/zim.mjs';
|
||||
import {promisify} from 'util';
|
||||
import {exec} from 'child_process';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const router = express.Router();
|
||||
|
||||
export default function adminController(config) {
|
||||
router.get('/download', (req, res) => {
|
||||
const filePath = req.query.path;
|
||||
if (!filePath || !filePath.endsWith('.zim')) return res.status(400).send('Invalid ZIM file path');
|
||||
res.download(filePath, (err) => {
|
||||
if(err) {
|
||||
console.error('Download error:', err.message);
|
||||
res.status(500).send('Download failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Download endpoint
|
||||
router.post('/download', async (req, res, next) => {
|
||||
try {
|
||||
const { url, filename, restartKiwix: shouldRestart = true } = req.body;
|
||||
if (!url) return res.status(400).json({ error: 'URL required' });
|
||||
|
||||
await ensureDirectoryExists(config.paths.zim);
|
||||
const finalFilename = filename || path.basename(new URL(url).pathname) || 'download.zim';
|
||||
const destination = path.join(config.paths.zim, finalFilename);
|
||||
|
||||
await downloadFile(url, destination);
|
||||
if (shouldRestart) await restartKiwix();
|
||||
|
||||
res.json({ message: 'Downloaded successfully', path: destination });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Upload endpoint
|
||||
router.post('/upload', upload(environment.paths.zim, true).single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
|
||||
await restartKiwix();
|
||||
res.json({ message: 'File uploaded successfully', files: [req.file.filename] });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/zim-zip', upload('/tmp/').single('zipFile'), async (req, res, next) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No ZIP file uploaded.' });
|
||||
|
||||
const zipPath = req.file.path;
|
||||
const tempDir = path.join('/tmp/', `unzipped-${Date.now()}`);
|
||||
|
||||
try {
|
||||
await ensureDirectoryExists(tempDir);
|
||||
await execAsync(`unzip -o "${zipPath}" -d "${tempDir}"`);
|
||||
|
||||
const { name, welcome, language, description, tags, favicon, creator } = req.body;
|
||||
if(!name) throw new Error('ZIM name is are required.');
|
||||
|
||||
const entries = await fs.readdir(tempDir, { withFileTypes: true });
|
||||
let contentPath = tempDir;
|
||||
const directories = entries.filter(e => e.isDirectory());
|
||||
if (directories.length === 1 && entries.length === 1) {
|
||||
contentPath = path.join(tempDir, directories[0].name);
|
||||
}
|
||||
|
||||
await createZimFromDirectory({ name, welcome, language, description, tags, favicon, creator }, contentPath);
|
||||
await restartKiwix();
|
||||
|
||||
res.json({ message: 'ZIM from ZIP created successfully' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
} finally {
|
||||
if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }).catch(e => console.error(`Failed to cleanup temp dir: ${e.message}`));
|
||||
if (zipPath) await fs.unlink(zipPath).catch(e => console.error(`Failed to cleanup zip file: ${e.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/zim-url', async (req, res, next) => {
|
||||
try {
|
||||
const { url, name, language, description, tags, regex } = req.body;
|
||||
if (!url || !name) {
|
||||
return res.status(400).json({ error: 'URL and ZIM Name are required.' });
|
||||
}
|
||||
|
||||
const zimFile = await createZimFromUrl({ url, name, language, description, tags, regex });
|
||||
await restartKiwix();
|
||||
|
||||
res.json({ message: 'ZIM created successfully from URL.', file: zimFile });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete file endpoint
|
||||
router.post('/delete', async (req, res, next) => {
|
||||
try {
|
||||
await deleteFile(req.body.path);
|
||||
await restartKiwix();
|
||||
res.json({ message: 'File deleted + Kiwix restarted' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Restart endpoint
|
||||
router.get('/restart', async (req, res, next) => {
|
||||
try {
|
||||
const ok = await restartKiwix();
|
||||
ok ? res.json({ message: 'Restarted' }) : res.status(500).json({ error: 'Failed restart' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Info
|
||||
router.get('/info', (req, res) => res.json(config));
|
||||
|
||||
// Health
|
||||
router.get('/health', async (req, res, next) => {
|
||||
try {
|
||||
const zimFiles = await getZimFiles(config.paths.zim);
|
||||
res.json({ status: 'healthy', zimFileCount: zimFiles.length });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'unhealthy', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
167
src/controllers/archive.mjs
Normal file
167
src/controllers/archive.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import {ensureDirectoryExists, upload} from '../services/files.mjs';
|
||||
import {environment} from '../services/environment.mjs';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
export default function archiveController() {
|
||||
const archiveRoot = environment.paths.archive;
|
||||
|
||||
// Security helper to validate paths
|
||||
function validatePath(requestedPath) {
|
||||
const normalizedArchiveRoot = path.resolve(archiveRoot);
|
||||
const normalizedPath = path.resolve(path.join(archiveRoot, requestedPath));
|
||||
|
||||
if (!normalizedPath.startsWith(normalizedArchiveRoot)) {
|
||||
throw new Error('Access denied: Path outside archive directory');
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
// Create folder endpoint
|
||||
router.post('/create-folder', async (req, res, next) => {
|
||||
try {
|
||||
const { path: requestedPath, name } = req.body;
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Folder name is required' });
|
||||
}
|
||||
|
||||
const parentPath = validatePath(requestedPath || '');
|
||||
const newFolderPath = path.join(parentPath, name.trim());
|
||||
|
||||
// Check if folder already exists
|
||||
try {
|
||||
await fs.access(newFolderPath);
|
||||
return res.status(409).json({ error: 'Folder already exists' });
|
||||
} catch {
|
||||
// Folder doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
await fs.mkdir(newFolderPath, { recursive: true });
|
||||
res.json({ message: 'Folder created successfully', path: newFolderPath });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Upload files endpoint
|
||||
router.post('/upload', upload('/tmp/', false).array('files'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const uploadPath = req.body.uploadPath || '';
|
||||
const targetDir = validatePath(uploadPath);
|
||||
|
||||
// Ensure target directory exists
|
||||
await ensureDirectoryExists(targetDir);
|
||||
|
||||
const uploadedFiles = [];
|
||||
for (const file of req.files) {
|
||||
const targetPath = path.join(targetDir, file.originalname);
|
||||
|
||||
// Use copyFile + unlink instead of rename for cross-device moves
|
||||
try {
|
||||
await fs.copyFile(file.path, targetPath);
|
||||
await fs.unlink(file.path);
|
||||
uploadedFiles.push(file.originalname);
|
||||
} catch (err) {
|
||||
// If copy fails, clean up the temp file
|
||||
try {
|
||||
await fs.unlink(file.path);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `${uploadedFiles.length} file(s) uploaded successfully`,
|
||||
files: uploadedFiles
|
||||
});
|
||||
} catch (err) {
|
||||
// Clean up any uploaded files if there's an error
|
||||
if (req.files) {
|
||||
for (const file of req.files) {
|
||||
try {
|
||||
await fs.unlink(file.path);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Rename file/folder endpoint
|
||||
router.post('/rename', async (req, res, next) => {
|
||||
try {
|
||||
const { path: requestedPath, oldName, newName } = req.body;
|
||||
if (!oldName || !newName || !newName.trim()) {
|
||||
return res.status(400).json({ error: 'Old name and new name are required' });
|
||||
}
|
||||
|
||||
const parentPath = validatePath(requestedPath || '');
|
||||
const oldPath = path.join(parentPath, oldName);
|
||||
const newPath = path.join(parentPath, newName.trim());
|
||||
|
||||
// Check if old path exists
|
||||
try {
|
||||
await fs.access(oldPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'File or folder not found' });
|
||||
}
|
||||
|
||||
// Check if new path already exists
|
||||
try {
|
||||
await fs.access(newPath);
|
||||
return res.status(409).json({ error: 'A file or folder with that name already exists' });
|
||||
} catch {
|
||||
// New path doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
await fs.rename(oldPath, newPath);
|
||||
res.json({ message: 'Renamed successfully' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete file/folder endpoint
|
||||
router.post('/delete', async (req, res, next) => {
|
||||
try {
|
||||
const { path: requestedPath, name, isDirectory } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const parentPath = validatePath(requestedPath || '');
|
||||
const targetPath = path.join(parentPath, name);
|
||||
|
||||
// Check if target exists
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'File or folder not found' });
|
||||
}
|
||||
|
||||
if (isDirectory === true || isDirectory === 'true') {
|
||||
await fs.rm(targetPath, { recursive: true, force: true });
|
||||
res.json({ message: 'Folder deleted successfully' });
|
||||
} else {
|
||||
await fs.unlink(targetPath);
|
||||
res.json({ message: 'File deleted successfully' });
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
108
src/controllers/views.mjs
Normal file
108
src/controllers/views.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import { renderAdminPanel } from '../views/admin.mjs';
|
||||
import {getDirectoryContents, getZimFiles} from '../services/files.mjs';
|
||||
import { environment } from '../services/environment.mjs';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import {renderArchive} from '../views/archive.mjs';
|
||||
|
||||
export const viewController = {
|
||||
adminPanel: async (req, res, next) => {
|
||||
try {
|
||||
const zimFiles = await getZimFiles(environment.paths.zim);
|
||||
res.send(renderAdminPanel(zimFiles));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
fileBrowser: async (req, res, next) => {
|
||||
try {
|
||||
const requestedPath = req.query.path || '';
|
||||
const currentPath = path.join(environment.paths.archive, requestedPath);
|
||||
const normalizedArchiveRoot = path.resolve(environment.paths.archive);
|
||||
const normalizedCurrentPath = path.resolve(currentPath);
|
||||
|
||||
if(!normalizedCurrentPath.startsWith(normalizedArchiveRoot))
|
||||
return res.status(403).send('Forbidden');
|
||||
|
||||
const files = await getDirectoryContents(currentPath);
|
||||
res.send(renderArchive(currentPath, files, environment.paths.archive));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
fileViewer: async (req, res, next) => {
|
||||
try {
|
||||
const filePath = req.query.file;
|
||||
if (!filePath) {
|
||||
return res.status(400).send('File path required');
|
||||
}
|
||||
|
||||
const archiveRoot = environment.paths.archive;
|
||||
const normalizedArchiveRoot = path.resolve(archiveRoot);
|
||||
const normalizedFilePath = path.resolve(filePath);
|
||||
|
||||
// Security check
|
||||
if (!normalizedFilePath.startsWith(normalizedArchiveRoot)) {
|
||||
return res.status(403).send('Access denied: File outside archive directory');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return res.status(404).send('File not found');
|
||||
}
|
||||
|
||||
const stats = await fs.stat(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
// Redirect to directory browser
|
||||
const relativePath = path.relative(archiveRoot, filePath);
|
||||
return res.redirect(`/files?path=${encodeURIComponent(relativePath)}`);
|
||||
}
|
||||
|
||||
// Determine how to serve the file based on its extension
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// For PDFs, images, and other web-viewable files, serve them directly
|
||||
if (['.pdf', '.jpg', '.jpeg', '.png', '.gif', '.txt', '.html', '.css', '.js'].includes(ext)) {
|
||||
res.sendFile(normalizedFilePath);
|
||||
} else {
|
||||
// For other files, trigger download
|
||||
res.download(normalizedFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
fileDownload: async (req, res, next) => {
|
||||
try {
|
||||
const filePath = req.query.file;
|
||||
if (!filePath) {
|
||||
return res.status(400).send('File path required');
|
||||
}
|
||||
|
||||
const archiveRoot = environment.paths.archive;
|
||||
const normalizedArchiveRoot = path.resolve(archiveRoot);
|
||||
const normalizedFilePath = path.resolve(filePath);
|
||||
|
||||
// Security check
|
||||
if (!normalizedFilePath.startsWith(normalizedArchiveRoot)) {
|
||||
return res.status(403).send('Access denied: File outside archive directory');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return res.status(404).send('File not found');
|
||||
}
|
||||
|
||||
res.download(normalizedFilePath);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
65
src/main.mjs
Normal file
65
src/main.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import express from 'express';
|
||||
import adminController from './controllers/admin.mjs';
|
||||
import {environment} from './services/environment.mjs';
|
||||
import {ensureDirectoryExists} from './services/files.mjs';
|
||||
import path from 'path';
|
||||
import {startKiwix} from './services/kiwix.mjs';
|
||||
import http from 'http';
|
||||
import archiveController from './controllers/archive.mjs';
|
||||
import {viewController} from './controllers/views.mjs';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({extended: true}));
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Routes
|
||||
app.get('/admin', viewController.adminPanel);
|
||||
app.use('/api', adminController(environment));
|
||||
app.use('/api/files', archiveController());
|
||||
app.get('/archive', viewController.fileBrowser);
|
||||
app.get('/archive/view', viewController.fileViewer);
|
||||
app.get('/archive/download', viewController.fileDownload);
|
||||
app.get('/favicon.:ext', (req, res) => res.sendFile(path.join(environment.root, "favicon.png")));
|
||||
|
||||
// Kiwix proxy
|
||||
app.use((req, res) => {
|
||||
const proxyReq = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: environment.kiwix.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `127.0.0.1:${environment.kiwix.port}`,
|
||||
},
|
||||
}, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error('Proxy error:', err);
|
||||
if(!res.headersSent) res.status(502).send('Bad Gateway');
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(environment.port, async () => {
|
||||
console.log(`\n🥝 Kiwix Manager started on http://localhost:${environment.port}\n`);
|
||||
await ensureDirectoryExists(environment.paths.archive);
|
||||
await ensureDirectoryExists(environment.paths.zim);
|
||||
await startKiwix();
|
||||
});
|
||||
|
||||
export default app;
|
||||
19
src/services/environment.mjs
Normal file
19
src/services/environment.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const environment = {
|
||||
paths: {
|
||||
archive: process.env.ARCHIVE_DIR || '/data/archive',
|
||||
zim: process.env.ZIM_DIR || '/data/zim',
|
||||
},
|
||||
port: process.env.PORT || 3001,
|
||||
root: dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
|
||||
kiwix: {
|
||||
manage: process.env.KIWIX_MANAGE || '/usr/bin/kiwix-manage',
|
||||
serve: process.env.KIWIX_SERVE || '/usr/bin/kiwix-serve',
|
||||
port: process.env.KIWIX_PORT || 8000,
|
||||
},
|
||||
}
|
||||
133
src/services/files.mjs
Normal file
133
src/services/files.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
import path from 'path';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
export async function getDirectoryContents(dirPath) {
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
} catch {
|
||||
// Create directory if it doesn't exist
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
console.log(dirPath, items);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item.name);
|
||||
const stats = await fs.stat(itemPath);
|
||||
|
||||
files.push({
|
||||
name: item.name,
|
||||
path: itemPath,
|
||||
isDirectory: item.isDirectory(),
|
||||
size: item.isDirectory() ? 0 : stats.size,
|
||||
modified: stats.mtime.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, both alphabetically
|
||||
return files.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function upload(path, zimOnly = false) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadPath = req.body.uploadPath || path;
|
||||
try {
|
||||
await fs.mkdir(uploadPath, { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
let finalFilename = file.originalname;
|
||||
if(req.body.filename && req.body.filename.trim() !== '') {
|
||||
finalFilename = req.body.filename.trim();
|
||||
if(!finalFilename.toLowerCase().endsWith('.zim')) {
|
||||
finalFilename += '.zim';
|
||||
}
|
||||
}
|
||||
cb(null, finalFilename);
|
||||
}
|
||||
});
|
||||
|
||||
return multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if(!zimOnly) cb(null, true);
|
||||
else if(/\.zim$/i.test(file.originalname)) cb(null, true);
|
||||
else cb(new Error('Only ZIM files allowed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
const pipelineAsync = promisify(pipeline);
|
||||
|
||||
export async function downloadFile(url, destination) {
|
||||
if(!url.endsWith('.zim')) throw new Error('Only ZIM files allowed');
|
||||
if(!destination.endsWith('.zim')) destination += '.zim';
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! ${response.status}`);
|
||||
const writer = createWriteStream(destination);
|
||||
await pipelineAsync(response.body, writer);
|
||||
return destination;
|
||||
}
|
||||
|
||||
export function parseSize(sizeStr) {
|
||||
if (sizeStr.toLowerCase() === 'infinity') return Infinity;
|
||||
const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
||||
const match = sizeStr.match(/^(\d+)(B|KB|MB|GB)$/i);
|
||||
if (!match) return 100 * 1024 * 1024; // default 100MB
|
||||
return parseInt(match[1]) * units[match[2].toUpperCase()];
|
||||
}
|
||||
|
||||
export async function ensureDirectoryExists(dirPath) {
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
} catch (err) {
|
||||
if (err.code !== 'EEXIST') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export async function getZimFiles(archiveDir) {
|
||||
await ensureDirectoryExists(archiveDir);
|
||||
const items = await fs.readdir(archiveDir, { withFileTypes: true });
|
||||
|
||||
const zimFiles = [];
|
||||
for (const item of items) {
|
||||
if (item.isFile() && path.extname(item.name).toLowerCase() === '.zim') {
|
||||
const fullPath = path.join(archiveDir, item.name);
|
||||
const stats = await fs.stat(fullPath);
|
||||
zimFiles.push({
|
||||
name: item.name,
|
||||
path: fullPath,
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return zimFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified));
|
||||
}
|
||||
|
||||
export async function deleteFile(filePath) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
96
src/services/kiwix.mjs
Normal file
96
src/services/kiwix.mjs
Normal file
@@ -0,0 +1,96 @@
|
||||
import {exec, spawn} from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { environment } from './environment.mjs';
|
||||
import { getZimFiles } from './files.mjs';
|
||||
import { resolve } from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
let kiwixProcess = null;
|
||||
|
||||
export async function startKiwix() {
|
||||
if(kiwixProcess) return;
|
||||
await synchronizeLibrary();
|
||||
const libraryPath = resolve(environment.paths.zim, 'library.xml');
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
kiwixProcess = spawn(environment.kiwix.serve, [`--port=${environment.kiwix.port}`, `--library`, libraryPath]);
|
||||
kiwixProcess.stdout.on('data', (data) => console.log(data.toString().trim()));
|
||||
kiwixProcess.stderr.on('data', (data) => console.error(`Kiwix Error: ${data.toString().trim()}`));
|
||||
kiwixProcess.on('error', (err) => {
|
||||
console.error('Failed to start Kiwix process.', err);
|
||||
kiwixProcess = null;
|
||||
rejectPromise(err);
|
||||
});
|
||||
kiwixProcess.on('close', (code) => {
|
||||
console.log(`Kiwix Serve exited with code ${code}`);
|
||||
kiwixProcess = null;
|
||||
});
|
||||
setTimeout(() => resolvePromise(), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
export function stopKiwix() {
|
||||
if (kiwixProcess) {
|
||||
kiwixProcess.kill('SIGTERM');
|
||||
console.log('Kiwix server stopped.');
|
||||
kiwixProcess = null;
|
||||
return true;
|
||||
}
|
||||
console.log('Kiwix server is not running.');
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function restartKiwix() {
|
||||
try {
|
||||
if(kiwixProcess) {
|
||||
stopKiwix();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
await startKiwix();
|
||||
console.log('Kiwix service restarted');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error restarting Kiwix:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function synchronizeLibrary() {
|
||||
const libraryPath = resolve(environment.paths.zim, 'library.xml');
|
||||
try {
|
||||
const { stdout: libraryOutput } = await execAsync(`${environment.kiwix.manage} ${libraryPath} show || echo`);
|
||||
const libraryBooks = new Set((libraryOutput.match(/ID: \w+, Path: (.*?),/g) || []).map(line => line.substring(line.indexOf('Path: ') + 6, line.length - 1)));
|
||||
|
||||
const zimFiles = await getZimFiles(environment.paths.zim);
|
||||
const directoryFiles = new Set(zimFiles.map(file => file.path));
|
||||
|
||||
const filesToAdd = [...directoryFiles].filter(file => file.endsWith('.zim') && !libraryBooks.has(file));
|
||||
const filesToRemove = [...libraryBooks].filter(file => !directoryFiles.has(file));
|
||||
|
||||
for (const file of filesToAdd) {
|
||||
console.log(`Adding file to library: ${file}`);
|
||||
await execAsync(`${environment.kiwix.manage} ${libraryPath} add ${file}`);
|
||||
}
|
||||
|
||||
for (const file of filesToRemove) {
|
||||
console.log(`Removing file from library: ${file}`);
|
||||
const { stdout } = await execAsync(`${environment.kiwix.manage} ${libraryPath} show`);
|
||||
const match = stdout.match(new RegExp(`ID: (\\w+), Path: ${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')},`));
|
||||
if (match && match[1]) {
|
||||
const bookId = match[1];
|
||||
await execAsync(`${environment.kiwix.manage} ${libraryPath} remove ${bookId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.stderr && (error.stderr.includes('No such file or directory') || error.stderr.includes('cannot open library'))) {
|
||||
console.log('Library file not found or is invalid, creating a new one.');
|
||||
const zimFiles = await getZimFiles(environment.paths.zim);
|
||||
for (const file of zimFiles.map(f => f.path)) {
|
||||
await execAsync(`${environment.kiwix.manage} ${libraryPath} add ${file}`);
|
||||
}
|
||||
} else {
|
||||
console.error('Error synchronizing library:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/services/zim.mjs
Normal file
81
src/services/zim.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { environment } from './environment.mjs';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const execAsync = promisify(exec, { timeout: 3600000 });
|
||||
|
||||
function escapeDockerArg(arg) {
|
||||
if (arg === null || arg === undefined || arg === '') return '""';
|
||||
return `"${String(arg).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function escapeShellArg(arg) {
|
||||
if (arg === null || arg === undefined || arg === '') return "''";
|
||||
return `'${String(arg).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export async function createZimFromDirectory(options, contentPath) {
|
||||
let { name, welcome, language, description, tags, favicon, creator } = options;
|
||||
const zimFile = name.endsWith('.zim') ? name : `${name}.zim`;
|
||||
const zimFilePath = path.join(environment.paths.zim, zimFile);
|
||||
if(!favicon) {
|
||||
fs.copyFile('/app/favicon.png', path.join(contentPath, 'favicon.png'));
|
||||
favicon = 'favicon.png';
|
||||
}
|
||||
|
||||
let command = `zimwriterfs -w ${escapeShellArg(welcome || 'index.html')} -I ${escapeShellArg(favicon)} -l ${escapeShellArg(language || 'eng')} -t ${escapeShellArg(name)} -n ${escapeShellArg(name)} -d ${escapeShellArg(description || 'ZIM Archive')} -c ${escapeShellArg(creator || 'Unknown')} -p KiwixM`;
|
||||
if(tags) command += ` --tags=${escapeShellArg(tags)}`;
|
||||
command += ` "${contentPath}" "${zimFilePath}"`;
|
||||
|
||||
console.log('Executing:', command);
|
||||
const { stdout, stderr } = await execAsync(command);
|
||||
if(stderr) console.error(stderr);
|
||||
return zimFile;
|
||||
}
|
||||
|
||||
export async function createZimFromUrl(options) {
|
||||
const { url, name, language, description, tags, regex, maxHops, waitEvent } = options;
|
||||
const sanitizedName = name.replace(/[^a-zA-Z0-9\-_]/g, '_');
|
||||
const zimFile = `${sanitizedName}.zim`;
|
||||
const zimFilePath = path.join(environment.paths.zim, zimFile);
|
||||
const tempDir = `/tmp/zimit-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
let zimitArgs = [];
|
||||
zimitArgs.push(`--seeds ${escapeDockerArg(url)}`);
|
||||
zimitArgs.push(`--name ${escapeDockerArg(name)}`);
|
||||
zimitArgs.push(`--headless`);
|
||||
if(description) zimitArgs.push(`--description ${escapeDockerArg(description)}`);
|
||||
if(language) zimitArgs.push(`--lang ${escapeDockerArg(language || 'en-US')}`);
|
||||
if(tags) zimitArgs.push(`--tags ${escapeDockerArg(tags)}`);
|
||||
if(regex) zimitArgs.push(`--include ${escapeDockerArg(regex)}`);
|
||||
if(maxHops && maxHops !== '') zimitArgs.push(`--max-hops ${parseInt(maxHops)}`);
|
||||
if(waitEvent && waitEvent !== '') zimitArgs.push(`--wait-until ${escapeDockerArg(waitEvent)}`);
|
||||
zimitArgs.push(`--output ${tempDir}`);
|
||||
|
||||
const containerName = `zimit-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
await execAsync(`mkdir -p ${tempDir}`);
|
||||
const dockerCommand = `docker run --name ${containerName} --rm -v ${tempDir}:${tempDir}:rw --shm-size=2g --cap-add=SYS_ADMIN --security-opt seccomp=unconfined ghcr.io/openzim/zimit zimit ${zimitArgs.join(' ')}`;
|
||||
|
||||
console.log('Executing:', dockerCommand);
|
||||
let { stdout, stderr } = await execAsync(dockerCommand);
|
||||
if(stderr) console.log(stderr);
|
||||
|
||||
try {
|
||||
const resp = await execAsync(`find ${tempDir} -name "*.zim" -type f`);
|
||||
const tempZimPath = resp.stdout?.split('\n')?.[0]?.trim();
|
||||
await fs.copyFile(tempZimPath, zimFilePath);
|
||||
return zimFile;
|
||||
} catch (fileError) {
|
||||
throw new Error(fileError.message);
|
||||
}
|
||||
} catch (err) {
|
||||
await execAsync(`docker rm -f ${containerName}`).catch(() => {});
|
||||
throw err;
|
||||
} finally {
|
||||
execAsync(`rm -rf ${tempDir}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
452
src/views/admin.mjs
Normal file
452
src/views/admin.mjs
Normal file
@@ -0,0 +1,452 @@
|
||||
import {formatFileSize} from '../services/files.mjs';
|
||||
|
||||
export function renderAdminPanel(zimFiles) {
|
||||
const totalSize = zimFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Kiwix Manager</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@keyframes spin { 0%{transform:rotate(0deg);} 100%{transform:rotate(360deg);} }
|
||||
.spinner { animation: spin 1s linear infinite; }
|
||||
.collapsible-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height .3s;
|
||||
}
|
||||
.collapsible-content.active { max-height: 800px; }
|
||||
.collapsible-icon { transition: transform .3s; }
|
||||
.collapsible-header.active .collapsible-icon { transform: rotate(180deg); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-50 min-h-screen p-5">
|
||||
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden">
|
||||
<div class="flex justify-between items-center p-8 border-b border-slate-200">
|
||||
<div class="flex items-center gap-4">
|
||||
<img alt="logo" src="/favicon.png" class="h-20 w-auto">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-800 mb-1">Kiwix Manager</h1>
|
||||
<p class="text-slate-500">Manage your offline content library</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 text-white font-semibold text-sm rounded-md hover:bg-blue-600 transition-all hover:-translate-y-0.5">Browse</a>
|
||||
<button onclick="restartKiwix()" class="inline-flex items-center gap-2 px-4 py-2 bg-amber-500 text-white font-semibold text-sm rounded-md hover:bg-amber-600 transition-all hover:-translate-y-0.5">Restart Kiwix</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 border-b border-slate-200 px-8 py-4 flex justify-between items-center flex-wrap gap-4">
|
||||
<div class="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span><strong>${zimFiles.length}</strong> ZIM files</span>
|
||||
<span>•</span>
|
||||
<span>${formatFileSize(totalSize)} total</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="https://library.kiwix.org/" target="_blank" class="text-blue-500 hover:text-blue-600 font-semibold">📚 Kiwix Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="alert" class="hidden mx-8 mt-4 p-4 rounded-lg justify-between items-center">
|
||||
<span id="alertMessage"></span>
|
||||
<button class="alert-close text-xl font-bold opacity-70 hover:opacity-100 bg-transparent border-0 cursor-pointer">×</button>
|
||||
</div>
|
||||
<div id="loading" class="hidden text-center p-8 mt-4">
|
||||
<div class="inline-block w-8 h-8 border-4 border-slate-200 border-t-blue-500 rounded-full spinner mb-4"></div>
|
||||
<p class="text-slate-600 font-medium">Processing...</p>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<div class="border border-slate-200 rounded-lg mb-4 bg-white overflow-hidden">
|
||||
<div class="collapsible-header bg-slate-50 px-6 py-5 cursor-pointer flex justify-between items-center font-semibold text-slate-700 hover:bg-slate-100 transition-colors" onclick="toggleCollapsible(this)">
|
||||
<span>📤 Upload ZIM</span><span class="collapsible-icon text-lg">▼</span>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div class="p-6">
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button id="upload-type-file-btn" class="flex-1 px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors" onclick="switchUploadType('file')">Upload File</button>
|
||||
<button id="upload-type-url-btn" class="flex-1 px-6 py-3 bg-slate-100 text-slate-600 font-semibold rounded-md hover:bg-slate-200 border border-slate-200 transition-colors" onclick="switchUploadType('url')">From URL</button>
|
||||
</div>
|
||||
|
||||
<div id="upload-file-section">
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
<div class="mb-4">
|
||||
<label for="zimFile" class="block mb-2 font-semibold text-slate-700 text-sm">Select ZIM File</label>
|
||||
<input type="file" id="zimFile" name="file" accept=".zim" required class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button type="submit" class="px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="upload-url-section" class="hidden">
|
||||
<form id="downloadForm">
|
||||
<div class="mb-4">
|
||||
<label for="downloadUrl" class="block mb-2 font-semibold text-slate-700 text-sm">Download URL</label>
|
||||
<input type="url" id="downloadUrl" name="url" required placeholder="https://download.kiwix.org/zim/..." class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="downloadFilename" class="block mb-2 font-semibold text-slate-700 text-sm">Filename (optional)</label>
|
||||
<input type="text" id="downloadFilename" name="filename" placeholder="Enter a new name for the file" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<button type="submit" class="px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors">Download</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-slate-200 rounded-lg mb-4 bg-white overflow-hidden">
|
||||
<div class="collapsible-header bg-slate-50 px-6 py-5 cursor-pointer flex justify-between items-center font-semibold text-slate-700 hover:bg-slate-100 transition-colors" onclick="toggleCollapsible(this)">
|
||||
<span>🌐 URL to ZIM</span><span class="collapsible-icon text-lg">▼</span>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div class="p-6">
|
||||
<form id="zim-url-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="zim-url-url" class="block mb-2 font-semibold text-slate-700 text-sm">URL</label>
|
||||
<input type="url" id="zim-url-url" name="url" required placeholder="https://www.example.com" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zim-url-name" class="block mb-2 font-semibold text-slate-700 text-sm">ZIM Name</label>
|
||||
<input type="text" id="zim-url-name" name="name" required placeholder="example-com-archive" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zim-url-language" class="block mb-2 font-semibold text-slate-700 text-sm">Language</label>
|
||||
<input type="text" id="zim-url-language" name="language" placeholder="en-US" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zim-url-description" class="block mb-2 font-semibold text-slate-700 text-sm">Description</label>
|
||||
<textarea id="zim-url-description" name="description" rows="2" placeholder="An archive of example.com" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="zim-url-tags" class="block mb-2 font-semibold text-slate-700 text-sm">Tags (semi-colon separator)</label>
|
||||
<input type="text" id="zim-url-tags" name="tags" placeholder="Cooking;Preppers" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zim-url-regex" class="block mb-2 font-semibold text-slate-700 text-sm">Include Regex</label>
|
||||
<input type="text" id="zim-url-regex" name="regex" placeholder="e.g., ^https://www.example.com/.*" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="zim-url-max-hops" class="block mb-2 font-semibold text-slate-700 text-sm">Max Hop Count</label>
|
||||
<input type="number" id="zim-url-max-hops" name="maxHops" min="0" max="10" placeholder="2" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
<p class="text-xs text-slate-500 mt-1">Maximum number of link hops to follow (0-10)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="zim-url-wait-event" class="block mb-2 font-semibold text-slate-700 text-sm">Wait Event</label>
|
||||
<select id="zim-url-wait-event" name="waitEvent" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
<option value="">Default</option>
|
||||
<option value="load">Page Load</option>
|
||||
<option value="domcontentloaded">DOM Ready</option>
|
||||
<option value="networkidle0">Network Idle (0 requests)</option>
|
||||
<option value="networkidle2">Network Idle (≤2 requests)</option>
|
||||
</select>
|
||||
<p class="text-xs text-slate-500 mt-1">When to consider page loading complete</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-slate-200 rounded-lg mb-4 bg-white overflow-hidden">
|
||||
<div class="collapsible-header bg-slate-50 px-6 py-5 cursor-pointer flex justify-between items-center font-semibold text-slate-700 hover:bg-slate-100 transition-colors" onclick="toggleCollapsible(this)">
|
||||
<span>📦 ZIP to ZIM</span><span class="collapsible-icon text-lg">▼</span>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div class="p-6">
|
||||
<form id="zimFromZipForm" enctype="multipart/form-data" class="space-y-4">
|
||||
<div>
|
||||
<label for="zip-file-input" class="block mb-2 font-semibold text-slate-700 text-sm">Select ZIP File</label>
|
||||
<input type="file" id="zip-file-input" name="zipFile" accept=".zip" required class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zip-name" class="block mb-2 font-semibold text-slate-700 text-sm">ZIM Name (required)</label>
|
||||
<input type="text" id="zip-name" name="name" required placeholder="My Awesome ZIM" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zip-welcome" class="block mb-2 font-semibold text-slate-700 text-sm">Welcome Page (path inside zip)</label>
|
||||
<input type="text" id="zip-welcome" name="welcome" placeholder="index.html" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zip-language" class="block mb-2 font-semibold text-slate-700 text-sm">Language</label>
|
||||
<input type="text" id="zip-language" name="language" placeholder="eng" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zip-description" class="block mb-2 font-semibold text-slate-700 text-sm">Description</label>
|
||||
<textarea id="zip-description" name="description" placeholder="A short description of the content" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="zip-tags" class="block mb-2 font-semibold text-slate-700 text-sm">Tags (semicolon-separated)</label>
|
||||
<input type="text" id="zip-tags" name="tags" placeholder="tag1;tag2;tag3" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zip-favicon" class="block mb-2 font-semibold text-slate-700 text-sm">Favicon (path inside zip)</label>
|
||||
<input type="text" id="zip-favicon" name="favicon" placeholder="images/favicon.png" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label for="zip-creator" class="block mb-2 font-semibold text-slate-700 text-sm">Creator</label>
|
||||
<input type="text" id="zip-creator" name="creator" placeholder="Your Name or ID" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start mt-8 mb-4 flex-wrap gap-4">
|
||||
<h2 class="text-slate-700 text-xl font-semibold">ZIM Library</h2>
|
||||
<input type="text" id="searchInput" placeholder="Search files..." class="flex-grow max-w-md px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 mt-4">
|
||||
${zimFiles.length === 0
|
||||
? `<div class="text-center py-12 text-slate-500">
|
||||
<div class="text-5xl mb-4 opacity-50">📚</div>
|
||||
<h3 class="text-lg font-semibold mb-2">No ZIM files found</h3>
|
||||
<p>Upload or download to get started</p>
|
||||
</div>`
|
||||
: zimFiles.map(file => `
|
||||
<div class="bg-white border border-slate-200 rounded-lg p-6 hover:border-slate-300 hover:shadow-md hover:-translate-y-0.5 transition-all">
|
||||
<div class="flex gap-4 mb-4">
|
||||
<div class="w-12 h-12 bg-slate-100 border border-slate-200 rounded-lg flex items-center justify-center text-2xl text-slate-500 flex-shrink-0">📚</div>
|
||||
<div class="flex-grow">
|
||||
<div class="font-bold text-lg text-slate-800 mb-1">${file.name}</div>
|
||||
<div class="text-slate-500 text-sm flex gap-4 flex-wrap">
|
||||
<span>${formatFileSize(file.size)}</span>
|
||||
<span>Modified: ${new Date(file.modified).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${!file.path.endsWith('archive.zim') ? `
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<a href="/api/download?path=${encodeURIComponent(file.path)}" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-600 font-semibold text-sm rounded-md hover:bg-slate-200 border border-slate-200 transition-colors">Download</a>
|
||||
<button onclick="deleteFile('${file.path.replace(/'/g, "\\'")}')" class="inline-flex items-center gap-2 px-4 py-2 bg-red-500 text-white font-semibold text-sm rounded-md hover:bg-red-600 transition-colors">Delete</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchUploadType(type) {
|
||||
const fileSection = document.getElementById('upload-file-section');
|
||||
const urlSection = document.getElementById('upload-url-section');
|
||||
const fileBtn = document.getElementById('upload-type-file-btn');
|
||||
const urlBtn = document.getElementById('upload-type-url-btn');
|
||||
|
||||
if (type === 'file') {
|
||||
fileSection.style.display = 'block';
|
||||
urlSection.style.display = 'none';
|
||||
fileBtn.className = 'flex-1 px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors';
|
||||
urlBtn.className = 'flex-1 px-6 py-3 bg-slate-100 text-slate-600 font-semibold rounded-md hover:bg-slate-200 border border-slate-200 transition-colors';
|
||||
} else {
|
||||
fileSection.style.display = 'none';
|
||||
urlSection.style.display = 'block';
|
||||
fileBtn.className = 'flex-1 px-6 py-3 bg-slate-100 text-slate-600 font-semibold rounded-md hover:bg-slate-200 border border-slate-200 transition-colors';
|
||||
urlBtn.className = 'flex-1 px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollapsible(header) {
|
||||
const content = header.nextElementSibling;
|
||||
header.classList.toggle('active'); content.classList.toggle('active');
|
||||
}
|
||||
|
||||
function showAlert(msg, type = 'success') {
|
||||
const alertEl = document.getElementById('alert');
|
||||
const messageEl = document.getElementById('alertMessage');
|
||||
if (!alertEl || !messageEl) return;
|
||||
|
||||
alertEl.className = type === 'success'
|
||||
? 'flex mx-8 mt-4 p-4 rounded-lg justify-between items-center bg-green-50 text-green-800 border border-green-200'
|
||||
: 'flex mx-8 mt-4 p-4 rounded-lg justify-between items-center bg-red-50 text-red-800 border border-red-200';
|
||||
messageEl.textContent = msg;
|
||||
}
|
||||
|
||||
function showLoading(show=true) {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.classList.toggle('hidden', !show);
|
||||
|
||||
// Disable/enable all submit buttons and action buttons
|
||||
const buttons = document.querySelectorAll('button[type="submit"], .bg-blue-500, .bg-amber-500, .bg-red-500');
|
||||
buttons.forEach(button => {
|
||||
button.disabled = show;
|
||||
if (show) {
|
||||
button.style.opacity = '0.6';
|
||||
button.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
button.style.opacity = '';
|
||||
button.style.cursor = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Disable/enable file inputs
|
||||
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||
fileInputs.forEach(input => {
|
||||
input.disabled = show;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function makeRequest(url, options={}) {
|
||||
try { const res = await fetch(url, options); return { ok: res.ok, data: await res.json() }; }
|
||||
catch(e){ return { ok:false, error:e.message }; }
|
||||
}
|
||||
|
||||
document.getElementById('downloadForm').addEventListener('submit', async e=>{
|
||||
e.preventDefault();
|
||||
showLoading(true);
|
||||
const body = { url: e.target.url.value, filename: e.target.filename.value };
|
||||
const result = await makeRequest('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
showLoading(false);
|
||||
if(result.ok){
|
||||
window.location.href = '?message=' + encodeURIComponent('Download finished') + '&type=success';
|
||||
} else {
|
||||
showAlert(result.data?.message || result.error || 'An unknown error occurred.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('uploadForm').addEventListener('submit', async e=>{
|
||||
e.preventDefault();
|
||||
const fd=new FormData(e.target);
|
||||
showLoading(true);
|
||||
const result=await makeRequest('/api/upload',{method:'POST',body:fd});
|
||||
showLoading(false);
|
||||
if(result.ok){
|
||||
window.location.href = '?message=Upload%20Finished&type=success';
|
||||
} else {
|
||||
showAlert(result.data?.message || result.error || 'An unknown error occurred.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle ZIM from URL form submission
|
||||
document.getElementById('zim-url-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Show loading animation
|
||||
showLoading(true);
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/zim-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Hide loading animation
|
||||
showLoading(false);
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh page with success message
|
||||
window.location.href = '?message=' + encodeURIComponent('Successfully created ZIM') + '&type=success';
|
||||
} else {
|
||||
// Show error alert
|
||||
showAlert('Error: ' + (result.error || result.message || 'An unknown error occurred.'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
// Hide loading animation
|
||||
showLoading(false);
|
||||
|
||||
console.error('Error creating ZIM from URL:', error);
|
||||
showAlert('A network error occurred: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('zimFromZipForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
showLoading(true);
|
||||
const formData = new FormData(form);
|
||||
const result = await makeRequest('/api/zim-zip', { method: 'POST', body: formData });
|
||||
showLoading(false);
|
||||
if (result.ok) {
|
||||
window.location.href = '?message=' + encodeURIComponent('Successfully created ZIM from ZIP') + '&type=success';
|
||||
} else {
|
||||
showAlert(result.data?.error || result.data?.message || result.error || 'An unknown error occurred.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteFile(filePath){
|
||||
if(filePath.endsWith('archive.zim')) return;
|
||||
if(!confirm('Delete this file?')) return;
|
||||
showLoading(true);
|
||||
const result=await makeRequest('/api/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:filePath})});
|
||||
showLoading(false);
|
||||
if(result.ok){
|
||||
window.location.href = '?message=ZIM%20Deleted&type=success';
|
||||
} else {
|
||||
showAlert(result.data?.message || result.error || 'An unknown error occurred.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function restartKiwix(){
|
||||
if (!confirm('Are you sure you want to restart Kiwix?')) return;
|
||||
showLoading(true);
|
||||
const result = await makeRequest('/api/restart');
|
||||
showLoading(false);
|
||||
if (result.ok) {
|
||||
showAlert('Kiwix has restarted', 'success');
|
||||
setTimeout(() => window.location.reload(), 5000);
|
||||
} else {
|
||||
showAlert(result.data?.message || result.error || 'Failed to restart Kiwix.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
document.getElementById('searchInput').addEventListener('input', function(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
const fileCards = document.querySelectorAll('.grid > div');
|
||||
fileCards.forEach(card => {
|
||||
const fileName = card.querySelector('.font-bold')?.textContent.toLowerCase();
|
||||
if (!fileName || fileName.includes(searchTerm)) {
|
||||
card.style.display = 'block';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Alert close functionality
|
||||
document.querySelector('.alert-close')?.addEventListener('click', function(e) {
|
||||
this.parentElement.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Handle initial message from query params
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const message = urlParams.get('message');
|
||||
const type = urlParams.get('type');
|
||||
if (message) {
|
||||
showAlert(decodeURIComponent(message), type || 'success');
|
||||
// clean url
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
330
src/views/archive.mjs
Normal file
330
src/views/archive.mjs
Normal file
@@ -0,0 +1,330 @@
|
||||
|
||||
import { formatFileSize } from '../services/files.mjs';
|
||||
import path from 'path';
|
||||
|
||||
// Move getFileIcon function to server-side
|
||||
function getFileIcon(fileName) {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
const icons = {
|
||||
pdf: '📄', txt: '📝', doc: '📄', jpg: '🖼️', png: '🖼️', gif: '🖼️',
|
||||
mp4: '🎥', mp3: '🎵', zip: '📦', html: '🌐', css: '🎨', js: '⚡'
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
|
||||
export function renderArchive(currentPath, files, archiveRoot) {
|
||||
const relativePath = path.relative(archiveRoot, currentPath);
|
||||
const pathSegments = relativePath === '' ? [] : relativePath.split(path.sep);
|
||||
|
||||
// Updated breadcrumbs: 🏠 / Archive / <path>
|
||||
const breadcrumbs = [
|
||||
{ name: '🏠', path: '/', isHome: true },
|
||||
{ name: 'Archive', path: '', isArchiveRoot: true }
|
||||
];
|
||||
|
||||
let accumulatedPath = '';
|
||||
pathSegments.forEach(segment => {
|
||||
accumulatedPath = accumulatedPath ? path.join(accumulatedPath, segment) : segment;
|
||||
breadcrumbs.push({ name: segment, path: accumulatedPath });
|
||||
});
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Kiwix Archive</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.spinner { border-color: #f3f4f6; border-top-color: #3b82f6; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.file-grid { display: grid; gap: 1rem; }
|
||||
.file-item:hover { transform: translateY(-1px); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen p-5">
|
||||
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-lg overflow-hidden border">
|
||||
<!-- Header (removed Home button) -->
|
||||
<div class="flex justify-between items-center p-8 border-b">
|
||||
<div class="flex items-center gap-4">
|
||||
<img src="/favicon.png" class="h-12 w-auto">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">Archive</h1>
|
||||
<p class="text-gray-500">Manage your offline content library</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Updated Breadcrumbs -->
|
||||
<div class="bg-gray-50 border-b px-8 py-4 flex items-center gap-2 flex-wrap">
|
||||
${breadcrumbs.map((crumb, i) => `
|
||||
${i > 0 ? '<span class="text-gray-400">/</span>' : ''}
|
||||
<a href="${crumb.isHome ? '/' : `/archive?path=${encodeURIComponent(crumb.path)}`}"
|
||||
class="text-gray-600 hover:text-gray-800 ${i === breadcrumbs.length - 1 ? 'font-semibold text-gray-800' : ''}">${crumb.name}</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="bg-gray-50 border-b p-6 flex justify-between items-center flex-wrap gap-4">
|
||||
<input type="text" id="searchInput" placeholder="Search files..."
|
||||
class="flex-1 max-w-md px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showModal('folder')" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">📁 New Folder</button>
|
||||
<button onclick="showModal('upload')" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">📤 Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert -->
|
||||
<div id="alert" class="hidden mx-8 mt-4 p-4 rounded-lg"></div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loading" class="hidden text-center p-8">
|
||||
<div class="spinner w-10 h-10 border-4 rounded-full mx-auto mb-4"></div>
|
||||
<p>Processing...</p>
|
||||
</div>
|
||||
|
||||
<!-- Files -->
|
||||
<div class="p-8">
|
||||
<div class="file-grid">
|
||||
${files.length === 0 ? `
|
||||
<div class="text-center py-16 text-gray-500">
|
||||
<div class="text-5xl mb-4 opacity-50">📂</div>
|
||||
<h3 class="text-lg font-semibold mb-2">No files found</h3>
|
||||
<p>This folder is empty. Upload files or create a folder to get started.</p>
|
||||
</div>
|
||||
` : files.map(file => `
|
||||
<div class="file-item bg-white border rounded-lg p-6 hover:shadow-md transition-all cursor-pointer flex items-center gap-4"
|
||||
data-path="${file.name}" data-dir="${file.isDirectory}">
|
||||
<div class="text-4xl">${file.isDirectory ? '📁' : getFileIcon(file.name)}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-gray-800 truncate">${file.name}</div>
|
||||
<div class="text-sm text-gray-500 flex gap-4">
|
||||
<span>${file.isDirectory ? 'Folder' : formatFileSize(file.size)}</span>
|
||||
<span>${new Date(file.modified).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions flex gap-2 flex-shrink-0">
|
||||
${!file.isDirectory ? `<a href="/archive/download?file=${encodeURIComponent(currentPath + '/' + file.name)}" class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Download</a>` : ''}
|
||||
<button onclick="rename('${file.name.replace(/'/g, "\\'")}', ${file.isDirectory})" class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Rename</button>
|
||||
<button onclick="deleteItem('${file.name.replace(/'/g, "\\'")}', ${file.isDirectory})" class="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 id="modalTitle" class="text-lg font-semibold"></h3>
|
||||
<button onclick="hideModal()" class="text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
<div id="modalContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const relativePath = '${relativePath.replace(/\\/g, '\\\\')}';
|
||||
|
||||
// Client-side version of getFileIcon (for consistency)
|
||||
function getFileIcon(fileName) {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
const icons = {
|
||||
pdf: '📄', txt: '📝', doc: '📄', jpg: '🖼️', png: '🖼️', gif: '🖼️',
|
||||
mp4: '🎥', mp3: '🎵', zip: '📦', html: '🌐', css: '🎨', js: '⚡'
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
|
||||
function showModal(type) {
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modalTitle');
|
||||
const content = document.getElementById('modalContent');
|
||||
|
||||
if (type === 'folder') {
|
||||
title.textContent = 'Create New Folder';
|
||||
content.innerHTML = \`
|
||||
<form onsubmit="createFolder(event)">
|
||||
<input type="text" name="name" placeholder="Folder name" required
|
||||
class="w-full px-3 py-2 border rounded-lg mb-4 focus:ring-2 focus:ring-blue-500">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick="hideModal()" class="px-4 py-2 bg-gray-200 rounded-lg">Cancel</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
\`;
|
||||
} else if (type === 'upload') {
|
||||
title.textContent = 'Upload Files';
|
||||
content.innerHTML = \`
|
||||
<form onsubmit="uploadFiles(event)" enctype="multipart/form-data">
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center mb-4 cursor-pointer"
|
||||
onclick="document.querySelector('input[type=file]').click()">
|
||||
<p>Click to select files or drag and drop</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Multiple files supported</p>
|
||||
</div>
|
||||
<input type="file" name="files" multiple class="hidden" onchange="updateFileList(this)">
|
||||
<div id="fileList" class="hidden mb-4 p-3 bg-gray-50 rounded-lg"></div>
|
||||
<input type="hidden" name="uploadPath" value="${relativePath}">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick="hideModal()" class="px-4 py-2 bg-gray-200 rounded-lg">Cancel</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
\`;
|
||||
} else if (type === 'rename') {
|
||||
title.textContent = 'Rename';
|
||||
content.innerHTML = \`
|
||||
<form onsubmit="doRename(event)">
|
||||
<input type="text" name="newName" required
|
||||
class="w-full px-3 py-2 border rounded-lg mb-4 focus:ring-2 focus:ring-blue-500">
|
||||
<input type="hidden" name="oldName">
|
||||
<input type="hidden" name="isDirectory">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick="hideModal()" class="px-4 py-2 bg-gray-200 rounded-lg">Cancel</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg">Rename</button>
|
||||
</div>
|
||||
</form>
|
||||
\`;
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
document.getElementById('modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showAlert(msg, type = 'success') {
|
||||
const alert = document.getElementById('alert');
|
||||
alert.className = \`px-4 py-3 rounded-lg \${type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}\`;
|
||||
alert.textContent = msg;
|
||||
alert.classList.remove('hidden');
|
||||
setTimeout(() => alert.classList.add('hidden'), 5000);
|
||||
}
|
||||
|
||||
function showLoading(show = true) {
|
||||
document.getElementById('loading').classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
async function makeRequest(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
return { ok: response.ok, data };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder(e) {
|
||||
e.preventDefault();
|
||||
const name = new FormData(e.target).get('name');
|
||||
showLoading();
|
||||
const result = await makeRequest('/api/files/create-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: relativePath, name })
|
||||
});
|
||||
showLoading(false);
|
||||
hideModal();
|
||||
if (result.ok) location.reload();
|
||||
else showAlert(result.data?.error || 'Failed to create folder', 'error');
|
||||
}
|
||||
|
||||
async function uploadFiles(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
showLoading();
|
||||
const result = await makeRequest('/api/files/upload', { method: 'POST', body: formData });
|
||||
showLoading(false);
|
||||
hideModal();
|
||||
if (result.ok) {
|
||||
showAlert('Files uploaded successfully');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else showAlert(result.data?.error || 'Upload failed', 'error');
|
||||
}
|
||||
|
||||
function rename(name, isDir) {
|
||||
showModal('rename');
|
||||
const form = document.querySelector('#modalContent form');
|
||||
form.newName.value = name;
|
||||
form.oldName.value = name;
|
||||
form.isDirectory.value = isDir;
|
||||
form.newName.select();
|
||||
}
|
||||
|
||||
async function doRename(e) {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.target));
|
||||
showLoading();
|
||||
const result = await makeRequest('/api/files/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: relativePath, ...data })
|
||||
});
|
||||
showLoading(false);
|
||||
hideModal();
|
||||
if (result.ok) location.reload();
|
||||
else showAlert(result.data?.error || 'Failed to rename', 'error');
|
||||
}
|
||||
|
||||
async function deleteItem(name, isDir) {
|
||||
if (!confirm(\`Delete "\${name}"?\`)) return;
|
||||
showLoading();
|
||||
const result = await makeRequest('/api/files/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: relativePath, name, isDirectory: isDir })
|
||||
});
|
||||
showLoading(false);
|
||||
if (result.ok) location.reload();
|
||||
else showAlert(result.data?.error || 'Failed to delete', 'error');
|
||||
}
|
||||
|
||||
function updateFileList(input) {
|
||||
const fileList = document.getElementById('fileList');
|
||||
if (input.files.length === 0) {
|
||||
fileList.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
fileList.classList.remove('hidden');
|
||||
fileList.innerHTML = \`<div class="font-semibold mb-2">Selected Files (\${input.files.length}):</div>\` +
|
||||
Array.from(input.files).map(f => \`<div class="text-sm text-gray-600">\${f.name}</div>\`).join('');
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.file-item').forEach(item => {
|
||||
const name = item.querySelector('.font-semibold').textContent.toLowerCase();
|
||||
item.style.display = name.includes(term) ? 'flex' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// File navigation
|
||||
document.querySelectorAll('.file-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.file-actions')) return;
|
||||
const isDir = item.dataset.dir === 'true';
|
||||
const name = item.dataset.path;
|
||||
if (isDir) {
|
||||
const newPath = relativePath ? \`\${relativePath}/\${name}\` : name;
|
||||
location.href = \`/archive?path=\${encodeURIComponent(newPath)}\`;
|
||||
} else {
|
||||
const fullPath = '${currentPath.replace(/\\/g, '\\\\')}'.replace(/\\\\/g, '/') + '/' + name;
|
||||
location.href = \`/archive/view?file=\${encodeURIComponent(fullPath)}\`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle URL messages
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const message = urlParams.get('message');
|
||||
if (message) {
|
||||
showAlert(decodeURIComponent(message), urlParams.get('type') || 'success');
|
||||
history.replaceState({}, document.title, location.pathname + '?path=' + encodeURIComponent(relativePath));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user