feat(hexojs): Add Gitea integration for content sync
- Add gitea config section to /etc/config/hexojs
- Add hexoctl gitea {setup|clone|sync|status} commands
- Token-based authentication for content repo cloning
- Auto-sync from Gitea to Hexo source directory
- Add comprehensive README documentation
Also:
- Create luci-app-metabolizer package with dashboard
- Update CMS pages with emoji names for Streamlit sidebar
- Bump hexojs to r2, metabolizer to r3
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
35957e34ab
commit
f9b73ea62c
22
package/secubox/luci-app-metabolizer/Makefile
Normal file
22
package/secubox/luci-app-metabolizer/Makefile
Normal file
@ -0,0 +1,22 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# Copyright (C) 2025 CyberMind.fr
|
||||
#
|
||||
# LuCI App for Metabolizer CMS
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=LuCI support for Metabolizer CMS
|
||||
LUCI_DEPENDS:=+luci-base +secubox-app-metabolizer
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_NAME:=luci-app-metabolizer
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
|
||||
PKG_LICENSE:=GPL-2.0
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildance, see include/package.mk
|
||||
$(eval $(call BuildPackage,$(PKG_NAME)))
|
||||
@ -0,0 +1,165 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require ui';
|
||||
'require rpc';
|
||||
'require poll';
|
||||
|
||||
var callStatus = rpc.declare({
|
||||
object: 'luci.metabolizer',
|
||||
method: 'status',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callListPosts = rpc.declare({
|
||||
object: 'luci.metabolizer',
|
||||
method: 'list_posts',
|
||||
expect: { '': [] }
|
||||
});
|
||||
|
||||
var callSync = rpc.declare({
|
||||
object: 'luci.metabolizer',
|
||||
method: 'sync',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callBuild = rpc.declare({
|
||||
object: 'luci.metabolizer',
|
||||
method: 'build',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
var callGiteaSync = rpc.declare({
|
||||
object: 'luci.metabolizer',
|
||||
method: 'gitea_sync',
|
||||
expect: {}
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return Promise.all([
|
||||
callStatus(),
|
||||
callListPosts()
|
||||
]);
|
||||
},
|
||||
|
||||
render: function(data) {
|
||||
var status = data[0] || {};
|
||||
var posts = data[1] || [];
|
||||
|
||||
var view = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, _('Metabolizer CMS')),
|
||||
|
||||
// Status cards
|
||||
E('div', { 'class': 'cbi-section', 'style': 'display: flex; gap: 1rem; flex-wrap: wrap;' }, [
|
||||
this.renderStatusCard('CMS', status.cms_running ? 'Running' : 'Stopped',
|
||||
status.cms_running ? 'green' : 'red'),
|
||||
this.renderStatusCard('Hexo', status.hexo_running ? 'Running' : 'Stopped',
|
||||
status.hexo_running ? 'green' : 'red'),
|
||||
this.renderStatusCard('Gitea', status.gitea_connected ? 'Connected' : 'Offline',
|
||||
status.gitea_connected ? 'green' : 'red'),
|
||||
this.renderStatusCard('Posts', status.post_count || 0, 'blue'),
|
||||
this.renderStatusCard('Drafts', status.draft_count || 0, 'orange')
|
||||
]),
|
||||
|
||||
// Quick links
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Quick Access')),
|
||||
E('div', { 'style': 'display: flex; gap: 1rem; flex-wrap: wrap;' }, [
|
||||
E('a', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'href': status.cms_url || '#',
|
||||
'target': '_blank'
|
||||
}, _('Open CMS Editor')),
|
||||
E('a', {
|
||||
'class': 'btn cbi-button',
|
||||
'href': status.blog_url || '/blog/',
|
||||
'target': '_blank'
|
||||
}, _('View Blog'))
|
||||
])
|
||||
]),
|
||||
|
||||
// Actions
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Actions')),
|
||||
E('div', { 'style': 'display: flex; gap: 1rem; flex-wrap: wrap;' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button',
|
||||
'click': ui.createHandlerFn(this, 'handleSync')
|
||||
}, _('Sync Content')),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button',
|
||||
'click': ui.createHandlerFn(this, 'handleBuild')
|
||||
}, _('Build Site')),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button',
|
||||
'click': ui.createHandlerFn(this, 'handleGiteaSync')
|
||||
}, _('Pull from Gitea'))
|
||||
])
|
||||
]),
|
||||
|
||||
// Recent posts
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('h3', {}, _('Recent Posts')),
|
||||
E('table', { 'class': 'table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, _('Title')),
|
||||
E('th', { 'class': 'th' }, _('Date')),
|
||||
E('th', { 'class': 'th' }, _('Slug'))
|
||||
]),
|
||||
E('tbody', {}, posts.slice(0, 10).map(function(post) {
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, post.title || '(untitled)'),
|
||||
E('td', { 'class': 'td' }, post.date || '-'),
|
||||
E('td', { 'class': 'td' }, post.slug || '-')
|
||||
]);
|
||||
}))
|
||||
])
|
||||
])
|
||||
]);
|
||||
|
||||
return view;
|
||||
},
|
||||
|
||||
renderStatusCard: function(label, value, color) {
|
||||
var colors = {
|
||||
'green': '#22c55e',
|
||||
'red': '#ef4444',
|
||||
'blue': '#3b82f6',
|
||||
'orange': '#f97316'
|
||||
};
|
||||
return E('div', {
|
||||
'style': 'background: var(--cbi-section-background); padding: 1rem; border-radius: 8px; min-width: 120px; text-align: center; border-left: 4px solid ' + (colors[color] || '#666') + ';'
|
||||
}, [
|
||||
E('div', { 'style': 'font-size: 0.9em; color: #666;' }, label),
|
||||
E('div', { 'style': 'font-size: 1.5em; font-weight: bold;' }, String(value))
|
||||
]);
|
||||
},
|
||||
|
||||
handleSync: function(ev) {
|
||||
return callSync().then(function() {
|
||||
ui.addNotification(null, E('p', _('Content synced successfully')));
|
||||
}).catch(function(e) {
|
||||
ui.addNotification(null, E('p', _('Sync failed: ') + e.message), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
handleBuild: function(ev) {
|
||||
return callBuild().then(function() {
|
||||
ui.addNotification(null, E('p', _('Site built successfully')));
|
||||
}).catch(function(e) {
|
||||
ui.addNotification(null, E('p', _('Build failed: ') + e.message), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
handleGiteaSync: function(ev) {
|
||||
return callGiteaSync().then(function() {
|
||||
ui.addNotification(null, E('p', _('Pulled from Gitea successfully')));
|
||||
}).catch(function(e) {
|
||||
ui.addNotification(null, E('p', _('Gitea sync failed: ') + e.message), 'error');
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
return view.extend({
|
||||
load: function() {
|
||||
return uci.load('metabolizer');
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var m, s, o;
|
||||
|
||||
m = new form.Map('metabolizer', _('Metabolizer Settings'),
|
||||
_('Configure the Metabolizer CMS pipeline settings.'));
|
||||
|
||||
// Main settings
|
||||
s = m.section(form.TypedSection, 'metabolizer', _('General'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.option(form.Value, 'gitea_url', _('Gitea URL'));
|
||||
o.placeholder = 'http://192.168.255.1:3000';
|
||||
|
||||
o = s.option(form.Value, 'gitea_user', _('Gitea Username'));
|
||||
o.placeholder = 'admin';
|
||||
|
||||
o = s.option(form.Value, 'webhook_port', _('Webhook Port'));
|
||||
o.placeholder = '8088';
|
||||
o.datatype = 'port';
|
||||
|
||||
// Content repository
|
||||
s = m.section(form.TypedSection, 'content', _('Content Repository'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.Value, 'repo_name', _('Repository Name'));
|
||||
o.placeholder = 'blog-content';
|
||||
|
||||
o = s.option(form.Value, 'repo_path', _('Local Path'));
|
||||
o.placeholder = '/srv/metabolizer/content';
|
||||
|
||||
o = s.option(form.Value, 'github_mirror', _('GitHub Mirror URL'),
|
||||
_('Optional GitHub URL to mirror'));
|
||||
o.optional = true;
|
||||
|
||||
// CMS settings
|
||||
s = m.section(form.TypedSection, 'cms', _('Streamlit CMS'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.Value, 'repo_name', _('CMS Repository'));
|
||||
o.placeholder = 'metabolizer-cms';
|
||||
|
||||
o = s.option(form.Value, 'streamlit_app', _('Streamlit App Name'));
|
||||
o.placeholder = 'metabolizer';
|
||||
|
||||
// Hexo integration
|
||||
s = m.section(form.TypedSection, 'hexo', _('Hexo Integration'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.Value, 'source_path', _('Hexo Source Path'));
|
||||
o.placeholder = '/srv/hexojs/site/source/_posts';
|
||||
|
||||
o = s.option(form.Value, 'public_path', _('Hexo Public Path'));
|
||||
o.placeholder = '/srv/hexojs/site/public';
|
||||
|
||||
o = s.option(form.Value, 'portal_path', _('Portal Path'));
|
||||
o.placeholder = '/www/blog';
|
||||
|
||||
o = s.option(form.Flag, 'auto_publish', _('Auto Publish'),
|
||||
_('Automatically publish to portal after build'));
|
||||
o.rmempty = false;
|
||||
|
||||
// Portal settings
|
||||
s = m.section(form.TypedSection, 'portal', _('Portal'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.option(form.Value, 'url_path', _('URL Path'));
|
||||
o.placeholder = '/blog';
|
||||
|
||||
o = s.option(form.Value, 'title', _('Portal Title'));
|
||||
o.placeholder = 'SecuBox Blog';
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,156 @@
|
||||
#!/bin/sh
|
||||
# RPCD backend for Metabolizer CMS LuCI app
|
||||
|
||||
. /lib/functions.sh
|
||||
|
||||
# Helpers
|
||||
json_output() {
|
||||
echo "$1"
|
||||
}
|
||||
|
||||
get_status() {
|
||||
local enabled running cms_running post_count draft_count
|
||||
local gitea_connected hexo_running
|
||||
|
||||
# Check metabolizer enabled
|
||||
enabled=$(uci -q get metabolizer.main.enabled || echo "0")
|
||||
|
||||
# Check Streamlit CMS app
|
||||
if pgrep -f "streamlit.*metabolizer" >/dev/null 2>&1; then
|
||||
cms_running="true"
|
||||
else
|
||||
cms_running="false"
|
||||
fi
|
||||
|
||||
# Check Hexo
|
||||
if lxc-info -n hexojs -s 2>/dev/null | grep -q "RUNNING"; then
|
||||
hexo_running="true"
|
||||
else
|
||||
hexo_running="false"
|
||||
fi
|
||||
|
||||
# Check Gitea
|
||||
if lxc-info -n gitea -s 2>/dev/null | grep -q "RUNNING"; then
|
||||
gitea_connected="true"
|
||||
else
|
||||
gitea_connected="false"
|
||||
fi
|
||||
|
||||
# Count posts/drafts
|
||||
local content_path=$(uci -q get metabolizer.content.repo_path || echo "/srv/metabolizer/content")
|
||||
post_count=0
|
||||
draft_count=0
|
||||
if [ -d "$content_path/_posts" ]; then
|
||||
post_count=$(ls -1 "$content_path/_posts/"*.md 2>/dev/null | wc -l)
|
||||
fi
|
||||
if [ -d "$content_path/_drafts" ]; then
|
||||
draft_count=$(ls -1 "$content_path/_drafts/"*.md 2>/dev/null | wc -l)
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
|
||||
"cms_running": $cms_running,
|
||||
"hexo_running": $hexo_running,
|
||||
"gitea_connected": $gitea_connected,
|
||||
"post_count": $post_count,
|
||||
"draft_count": $draft_count,
|
||||
"cms_url": "http://$(uci -q get network.lan.ipaddr || echo "192.168.1.1"):8501",
|
||||
"blog_url": "/blog/"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
list_posts() {
|
||||
local content_path=$(uci -q get metabolizer.content.repo_path || echo "/srv/metabolizer/content")
|
||||
local posts_dir="$content_path/_posts"
|
||||
|
||||
echo "["
|
||||
local first=1
|
||||
if [ -d "$posts_dir" ]; then
|
||||
for f in "$posts_dir"/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
local filename=$(basename "$f")
|
||||
local slug="${filename%.md}"
|
||||
local title=$(grep -m1 "^title:" "$f" 2>/dev/null | sed 's/^title:[[:space:]]*//' | tr -d '"' | tr -d "'")
|
||||
local date=$(grep -m1 "^date:" "$f" 2>/dev/null | sed 's/^date:[[:space:]]*//')
|
||||
|
||||
[ "$first" = "1" ] || echo ","
|
||||
first=0
|
||||
echo " {\"slug\": \"$slug\", \"title\": \"$title\", \"date\": \"$date\"}"
|
||||
done
|
||||
fi
|
||||
echo "]"
|
||||
}
|
||||
|
||||
gitea_status() {
|
||||
local content_path=$(uci -q get metabolizer.content.repo_path || echo "/srv/metabolizer/content")
|
||||
|
||||
local has_repo="false"
|
||||
local last_sync=""
|
||||
local branch=""
|
||||
|
||||
if [ -d "$content_path/.git" ]; then
|
||||
has_repo="true"
|
||||
cd "$content_path"
|
||||
last_sync=$(git log -1 --format="%ci" 2>/dev/null || echo "never")
|
||||
branch=$(git branch --show-current 2>/dev/null || echo "unknown")
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"has_repo": $has_repo,
|
||||
"last_sync": "$last_sync",
|
||||
"branch": "$branch"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
do_sync() {
|
||||
metabolizerctl sync 2>&1
|
||||
echo '{"status": "ok"}'
|
||||
}
|
||||
|
||||
do_build() {
|
||||
metabolizerctl build 2>&1
|
||||
echo '{"status": "ok"}'
|
||||
}
|
||||
|
||||
do_publish() {
|
||||
metabolizerctl publish 2>&1
|
||||
echo '{"status": "ok"}'
|
||||
}
|
||||
|
||||
do_gitea_sync() {
|
||||
hexoctl gitea sync 2>&1
|
||||
echo '{"status": "ok"}'
|
||||
}
|
||||
|
||||
# RPCD interface
|
||||
case "$1" in
|
||||
list)
|
||||
cat <<EOF
|
||||
{
|
||||
"status": {},
|
||||
"list_posts": {},
|
||||
"gitea_status": {},
|
||||
"sync": {},
|
||||
"build": {},
|
||||
"publish": {},
|
||||
"gitea_sync": {}
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
call)
|
||||
case "$2" in
|
||||
status) get_status ;;
|
||||
list_posts) list_posts ;;
|
||||
gitea_status) gitea_status ;;
|
||||
sync) do_sync ;;
|
||||
build) do_build ;;
|
||||
publish) do_publish ;;
|
||||
gitea_sync) do_gitea_sync ;;
|
||||
*) echo '{"error": "unknown method"}' ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
@ -0,0 +1,30 @@
|
||||
{
|
||||
"admin/services/metabolizer": {
|
||||
"title": "Metabolizer CMS",
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "metabolizer/overview"
|
||||
},
|
||||
"depends": {
|
||||
"acl": ["luci-app-metabolizer"],
|
||||
"uci": {"metabolizer": true}
|
||||
},
|
||||
"order": 85
|
||||
},
|
||||
"admin/services/metabolizer/overview": {
|
||||
"title": "Overview",
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "metabolizer/overview"
|
||||
},
|
||||
"order": 10
|
||||
},
|
||||
"admin/services/metabolizer/settings": {
|
||||
"title": "Settings",
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "metabolizer/settings"
|
||||
},
|
||||
"order": 20
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
{
|
||||
"luci-app-metabolizer": {
|
||||
"description": "Grant access to Metabolizer CMS",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.metabolizer": ["status", "list_posts", "gitea_status"]
|
||||
},
|
||||
"uci": ["metabolizer"]
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"luci.metabolizer": ["sync", "build", "publish", "gitea_sync"]
|
||||
},
|
||||
"uci": ["metabolizer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=secubox-app-hexojs
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_RELEASE:=2
|
||||
PKG_ARCH:=all
|
||||
|
||||
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
|
||||
|
||||
262
package/secubox/secubox-app-hexojs/README.md
Normal file
262
package/secubox/secubox-app-hexojs/README.md
Normal file
@ -0,0 +1,262 @@
|
||||
# SecuBox HexoJS
|
||||
|
||||
Self-hosted static blog generator for OpenWrt with Gitea integration.
|
||||
|
||||
## Features
|
||||
|
||||
- Hexo 8.x static site generator with Node.js 22 LTS
|
||||
- CyberMind theme with dark mode and modern design
|
||||
- Gitea integration for content management
|
||||
- Post and page management with Markdown
|
||||
- Media library for images and files
|
||||
- GitHub Pages deployment support
|
||||
- Preview server for local testing
|
||||
|
||||
Runs in LXC container with Alpine Linux.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install the package
|
||||
opkg install secubox-app-hexojs
|
||||
|
||||
# Setup container and create site
|
||||
hexoctl install
|
||||
hexoctl site create default
|
||||
|
||||
# Enable and start service
|
||||
uci set hexojs.main.enabled=1
|
||||
uci commit hexojs
|
||||
/etc/init.d/hexojs enable
|
||||
/etc/init.d/hexojs start
|
||||
```
|
||||
|
||||
Preview at `http://<router-ip>:4000`
|
||||
|
||||
## Commands
|
||||
|
||||
### Container Management
|
||||
|
||||
```bash
|
||||
hexoctl install # Download and setup LXC container
|
||||
hexoctl uninstall # Remove container (keeps data)
|
||||
hexoctl update # Update Hexo and dependencies
|
||||
hexoctl status # Show service status
|
||||
hexoctl shell # Open shell in container
|
||||
hexoctl logs # View container logs
|
||||
hexoctl exec <cmd> # Execute command in container
|
||||
```
|
||||
|
||||
### Site Management
|
||||
|
||||
```bash
|
||||
hexoctl site create <name> # Create new Hexo site
|
||||
hexoctl site list # List all sites
|
||||
hexoctl site delete <name> # Delete a site
|
||||
```
|
||||
|
||||
### Content Commands
|
||||
|
||||
```bash
|
||||
hexoctl new post "Title" # Create new blog post
|
||||
hexoctl new page "Title" # Create new page
|
||||
hexoctl new draft "Title" # Create new draft
|
||||
hexoctl publish <slug> # Publish a draft
|
||||
hexoctl list posts # List all posts (JSON)
|
||||
hexoctl list drafts # List all drafts (JSON)
|
||||
```
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
hexoctl serve # Start preview server (port 4000)
|
||||
hexoctl build # Generate static files
|
||||
hexoctl clean # Clean generated files
|
||||
hexoctl deploy # Deploy to configured target
|
||||
```
|
||||
|
||||
## Gitea Integration
|
||||
|
||||
Sync blog content from a Gitea repository.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Enable Gitea integration
|
||||
uci set hexojs.gitea.enabled=1
|
||||
uci set hexojs.gitea.url='http://192.168.255.1:3000'
|
||||
uci set hexojs.gitea.user='admin'
|
||||
uci set hexojs.gitea.token='your-gitea-access-token'
|
||||
uci set hexojs.gitea.content_repo='blog-content'
|
||||
uci set hexojs.gitea.content_branch='main'
|
||||
uci commit hexojs
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
hexoctl gitea setup # Configure git credentials in container
|
||||
hexoctl gitea clone # Clone content repo from Gitea
|
||||
hexoctl gitea sync # Pull latest content and sync to Hexo
|
||||
hexoctl gitea status # Show Gitea sync status (JSON)
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Gitea │───►│ HexoJS │───►│ Portal │
|
||||
│ Content │ │ Build │ │ Static │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
blog-content/ hexo generate /www/blog/
|
||||
_posts/*.md public/ index.html
|
||||
```
|
||||
|
||||
1. Create/edit posts in Gitea repository
|
||||
2. Run `hexoctl gitea sync` to pull changes
|
||||
3. Run `hexoctl build` to generate static site
|
||||
4. Static files available in `/srv/hexojs/site/public/`
|
||||
|
||||
### Content Repository Structure
|
||||
|
||||
```
|
||||
blog-content/
|
||||
├── _posts/ # Published posts
|
||||
│ └── 2025-01-24-hello-world.md
|
||||
├── _drafts/ # Draft posts
|
||||
├── images/ # Media files
|
||||
├── about/ # About page
|
||||
├── portfolio/ # Portfolio page
|
||||
└── services/ # Services page
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `/etc/config/hexojs`:
|
||||
|
||||
```
|
||||
config hexojs 'main'
|
||||
option enabled '1'
|
||||
option http_port '4000'
|
||||
option data_path '/srv/hexojs'
|
||||
option active_site 'default'
|
||||
option memory_limit '512M'
|
||||
|
||||
config site 'default'
|
||||
option title 'My Blog'
|
||||
option subtitle 'Self-hosted on OpenWrt'
|
||||
option author 'Admin'
|
||||
option language 'en'
|
||||
option theme 'cybermind'
|
||||
option url 'http://localhost:4000'
|
||||
option root '/'
|
||||
option per_page '10'
|
||||
|
||||
config deploy 'deploy'
|
||||
option type 'git'
|
||||
option repo ''
|
||||
option branch 'gh-pages'
|
||||
|
||||
config gitea 'gitea'
|
||||
option enabled '0'
|
||||
option url 'http://192.168.255.1:3000'
|
||||
option user 'admin'
|
||||
option token ''
|
||||
option content_repo 'blog-content'
|
||||
option content_branch 'main'
|
||||
option auto_sync '0'
|
||||
|
||||
config theme_config 'theme'
|
||||
option default_mode 'dark'
|
||||
option allow_toggle '1'
|
||||
option accent_color '#f97316'
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
/srv/hexojs/
|
||||
├── site/ # Hexo site
|
||||
│ ├── source/
|
||||
│ │ ├── _posts/ # Blog posts
|
||||
│ │ ├── _drafts/ # Drafts
|
||||
│ │ └── images/ # Media
|
||||
│ ├── themes/
|
||||
│ │ └── cybermind/ # CyberMind theme
|
||||
│ ├── public/ # Generated static files
|
||||
│ └── _config.yml # Hexo config
|
||||
├── content/ # Cloned Gitea content repo
|
||||
├── themes/ # Shared themes
|
||||
└── media/ # Shared media
|
||||
```
|
||||
|
||||
## CyberMind Theme
|
||||
|
||||
Included dark theme with:
|
||||
|
||||
- Responsive design
|
||||
- Dark/light mode toggle
|
||||
- Orange accent color (#f97316)
|
||||
- Terminal-style logo
|
||||
- Categories and tags support
|
||||
- Apps portfolio section
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container not starting
|
||||
|
||||
```bash
|
||||
# Check container status
|
||||
lxc-info -n hexojs
|
||||
|
||||
# View logs
|
||||
hexoctl logs
|
||||
|
||||
# Reinstall container
|
||||
hexoctl uninstall
|
||||
hexoctl install
|
||||
```
|
||||
|
||||
### Gitea clone fails
|
||||
|
||||
```bash
|
||||
# Verify credentials
|
||||
hexoctl gitea status
|
||||
|
||||
# Re-setup git credentials
|
||||
hexoctl gitea setup
|
||||
|
||||
# Check token has repo access
|
||||
curl -H "Authorization: token YOUR_TOKEN" \
|
||||
http://192.168.255.1:3000/api/v1/user/repos
|
||||
```
|
||||
|
||||
### Build errors
|
||||
|
||||
```bash
|
||||
# Clean and rebuild
|
||||
hexoctl clean
|
||||
hexoctl build
|
||||
|
||||
# Check inside container
|
||||
hexoctl shell
|
||||
cd /opt/hexojs/site
|
||||
npm install
|
||||
hexo generate --debug
|
||||
```
|
||||
|
||||
## Integration with Metabolizer
|
||||
|
||||
HexoJS works with the Metabolizer CMS pipeline:
|
||||
|
||||
```
|
||||
Streamlit CMS → Gitea → HexoJS → Portal
|
||||
(edit) (store) (build) (serve)
|
||||
```
|
||||
|
||||
See `secubox-app-metabolizer` for the full CMS experience.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - CyberMind Studio 2025
|
||||
@ -23,6 +23,15 @@ config deploy 'deploy'
|
||||
option branch 'gh-pages'
|
||||
option message 'Site updated: {{ now("YYYY-MM-DD HH:mm:ss") }}'
|
||||
|
||||
config gitea 'gitea'
|
||||
option enabled '0'
|
||||
option url 'http://192.168.255.1:3000'
|
||||
option user 'admin'
|
||||
option token ''
|
||||
option content_repo 'blog-content'
|
||||
option content_branch 'main'
|
||||
option auto_sync '0'
|
||||
|
||||
config theme_config 'theme'
|
||||
option default_mode 'dark'
|
||||
option allow_toggle '1'
|
||||
|
||||
@ -56,6 +56,15 @@ load_config() {
|
||||
deploy_repo="$(uci_get deploy.repo)" || deploy_repo=""
|
||||
deploy_branch="$(uci_get deploy.branch)" || deploy_branch="gh-pages"
|
||||
|
||||
# Gitea config
|
||||
gitea_enabled="$(uci_get gitea.enabled)" || gitea_enabled="0"
|
||||
gitea_url="$(uci_get gitea.url)" || gitea_url="http://192.168.255.1:3000"
|
||||
gitea_user="$(uci_get gitea.user)" || gitea_user="admin"
|
||||
gitea_token="$(uci_get gitea.token)" || gitea_token=""
|
||||
gitea_content_repo="$(uci_get gitea.content_repo)" || gitea_content_repo="blog-content"
|
||||
gitea_content_branch="$(uci_get gitea.content_branch)" || gitea_content_branch="main"
|
||||
gitea_auto_sync="$(uci_get gitea.auto_sync)" || gitea_auto_sync="0"
|
||||
|
||||
ensure_dir "$data_path"
|
||||
ensure_dir "$data_path/sites"
|
||||
ensure_dir "$data_path/media"
|
||||
@ -98,6 +107,12 @@ Service Commands:
|
||||
service-run Run in foreground (for init)
|
||||
service-stop Stop service
|
||||
|
||||
Gitea Integration:
|
||||
gitea setup Configure git credentials in container
|
||||
gitea clone Clone content repo from Gitea
|
||||
gitea sync Pull latest content from Gitea
|
||||
gitea status Show Gitea sync status
|
||||
|
||||
Utility:
|
||||
shell Open shell in container
|
||||
logs View container logs
|
||||
@ -837,6 +852,194 @@ cmd_service_stop() {
|
||||
lxc_stop
|
||||
}
|
||||
|
||||
# Gitea integration commands
|
||||
cmd_gitea_setup() {
|
||||
require_root
|
||||
load_config
|
||||
|
||||
if [ -z "$gitea_token" ]; then
|
||||
log_error "Gitea token not configured"
|
||||
log_info "Set with: uci set hexojs.gitea.token='your-token' && uci commit hexojs"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! lxc_running; then
|
||||
log_error "Container not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Configuring git credentials for Gitea..."
|
||||
|
||||
# Extract host from URL
|
||||
local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||')
|
||||
|
||||
# Configure git credential helper in container
|
||||
lxc_exec sh -c "
|
||||
git config --global user.name '$gitea_user'
|
||||
git config --global user.email '${gitea_user}@localhost'
|
||||
git config --global credential.helper store
|
||||
|
||||
# Store credentials
|
||||
mkdir -p ~/.git-credentials
|
||||
cat > ~/.git-credentials << CRED
|
||||
https://${gitea_user}:${gitea_token}@${gitea_host}
|
||||
http://${gitea_user}:${gitea_token}@${gitea_host}
|
||||
CRED
|
||||
chmod 600 ~/.git-credentials
|
||||
git config --global credential.helper 'store --file ~/.git-credentials'
|
||||
"
|
||||
|
||||
log_info "Git credentials configured"
|
||||
}
|
||||
|
||||
cmd_gitea_clone() {
|
||||
require_root
|
||||
load_config
|
||||
|
||||
if [ "$gitea_enabled" != "1" ]; then
|
||||
log_error "Gitea integration not enabled"
|
||||
log_info "Enable with: uci set hexojs.gitea.enabled=1 && uci commit hexojs"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$gitea_token" ]; then
|
||||
log_error "Gitea token not configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! lxc_running; then
|
||||
log_error "Container not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local content_path="$data_path/content"
|
||||
local site_source="$data_path/site/source"
|
||||
|
||||
# Clone content repo
|
||||
if [ -d "$content_path/.git" ]; then
|
||||
log_info "Content repo already cloned, pulling latest..."
|
||||
cd "$content_path" && git pull
|
||||
else
|
||||
log_info "Cloning content repo from Gitea..."
|
||||
|
||||
# Build clone URL with token
|
||||
local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||')
|
||||
local clone_url="http://${gitea_user}:${gitea_token}@${gitea_host}/${gitea_user}/${gitea_content_repo}.git"
|
||||
|
||||
ensure_dir "$(dirname "$content_path")"
|
||||
rm -rf "$content_path"
|
||||
|
||||
git clone -b "$gitea_content_branch" "$clone_url" "$content_path" || {
|
||||
log_error "Failed to clone content repo"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Sync to hexo source
|
||||
cmd_gitea_sync_files
|
||||
|
||||
log_info "Content cloned successfully"
|
||||
}
|
||||
|
||||
cmd_gitea_sync() {
|
||||
require_root
|
||||
load_config
|
||||
|
||||
local content_path="$data_path/content"
|
||||
|
||||
if [ ! -d "$content_path/.git" ]; then
|
||||
log_error "Content repo not cloned. Run: hexoctl gitea clone"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Pulling latest content from Gitea..."
|
||||
|
||||
cd "$content_path" && git pull || {
|
||||
log_error "Git pull failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_gitea_sync_files
|
||||
|
||||
log_info "Content synced"
|
||||
}
|
||||
|
||||
cmd_gitea_sync_files() {
|
||||
load_config
|
||||
|
||||
local content_path="$data_path/content"
|
||||
local site_source="$data_path/site/source"
|
||||
|
||||
if [ ! -d "$site_source" ]; then
|
||||
log_error "Hexo site not created. Run: hexoctl site create default"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Syncing content files to Hexo source..."
|
||||
|
||||
# Sync _posts
|
||||
if [ -d "$content_path/_posts" ]; then
|
||||
ensure_dir "$site_source/_posts"
|
||||
cp -r "$content_path/_posts/"* "$site_source/_posts/" 2>/dev/null || true
|
||||
log_info "Synced _posts"
|
||||
fi
|
||||
|
||||
# Sync _drafts
|
||||
if [ -d "$content_path/_drafts" ]; then
|
||||
ensure_dir "$site_source/_drafts"
|
||||
cp -r "$content_path/_drafts/"* "$site_source/_drafts/" 2>/dev/null || true
|
||||
log_info "Synced _drafts"
|
||||
fi
|
||||
|
||||
# Sync images
|
||||
if [ -d "$content_path/images" ]; then
|
||||
ensure_dir "$site_source/images"
|
||||
cp -r "$content_path/images/"* "$site_source/images/" 2>/dev/null || true
|
||||
log_info "Synced images"
|
||||
fi
|
||||
|
||||
# Sync pages (about, etc)
|
||||
for page in about portfolio services; do
|
||||
if [ -d "$content_path/$page" ]; then
|
||||
ensure_dir "$site_source/$page"
|
||||
cp -r "$content_path/$page/"* "$site_source/$page/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
cmd_gitea_status() {
|
||||
load_config
|
||||
|
||||
local content_path="$data_path/content"
|
||||
local has_repo="false"
|
||||
local last_commit=""
|
||||
local remote_url=""
|
||||
local branch=""
|
||||
|
||||
if [ -d "$content_path/.git" ]; then
|
||||
has_repo="true"
|
||||
cd "$content_path"
|
||||
last_commit=$(git log -1 --format="%h %s" 2>/dev/null || echo "unknown")
|
||||
remote_url=$(git remote get-url origin 2>/dev/null | sed "s|${gitea_token}|***|g" || echo "none")
|
||||
branch=$(git branch --show-current 2>/dev/null || echo "unknown")
|
||||
fi
|
||||
|
||||
cat << EOF
|
||||
{
|
||||
"gitea_enabled": $([ "$gitea_enabled" = "1" ] && echo "true" || echo "false"),
|
||||
"gitea_url": "$gitea_url",
|
||||
"gitea_user": "$gitea_user",
|
||||
"content_repo": "$gitea_content_repo",
|
||||
"content_branch": "$gitea_content_branch",
|
||||
"has_local_repo": $has_repo,
|
||||
"local_branch": "$branch",
|
||||
"last_commit": "$last_commit",
|
||||
"remote_url": "$remote_url",
|
||||
"auto_sync": $([ "$gitea_auto_sync" = "1" ] && echo "true" || echo "false")
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
# Main
|
||||
case "${1:-}" in
|
||||
install) shift; cmd_install "$@" ;;
|
||||
@ -894,5 +1097,16 @@ case "${1:-}" in
|
||||
service-run) shift; cmd_service_run "$@" ;;
|
||||
service-stop) shift; cmd_service_stop "$@" ;;
|
||||
|
||||
gitea)
|
||||
shift
|
||||
case "${1:-}" in
|
||||
setup) shift; cmd_gitea_setup "$@" ;;
|
||||
clone) shift; cmd_gitea_clone "$@" ;;
|
||||
sync) shift; cmd_gitea_sync "$@" ;;
|
||||
status) shift; cmd_gitea_status "$@" ;;
|
||||
*) echo "Usage: hexoctl gitea {setup|clone|sync|status}" ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=secubox-app-metabolizer
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=2
|
||||
PKG_RELEASE:=3
|
||||
PKG_ARCH:=all
|
||||
|
||||
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
"""
|
||||
Metabolizer CMS - SecuBox Blog Management
|
||||
Main entry point with navigation sidebar
|
||||
Multi-page Streamlit app for blog content management
|
||||
"""
|
||||
import streamlit as st
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Configuration
|
||||
CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content'))
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Metabolizer CMS",
|
||||
@ -11,117 +16,104 @@ st.set_page_config(
|
||||
initial_sidebar_state="expanded"
|
||||
)
|
||||
|
||||
# Cyberpunk styling
|
||||
# Custom CSS
|
||||
st.markdown("""
|
||||
<style>
|
||||
/* CRT Monitor Effect */
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
|
||||
.stApp {
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #0a0a0a 100%);
|
||||
}
|
||||
|
||||
/* Neon glow effect */
|
||||
h1, h2, h3 {
|
||||
color: #ff5f1f !important;
|
||||
text-shadow: 0 0 10px rgba(255, 95, 31, 0.5);
|
||||
}
|
||||
|
||||
/* Sidebar styling */
|
||||
.css-1d391kg {
|
||||
background: rgba(10, 10, 20, 0.95);
|
||||
border-right: 1px solid #ff5f1f;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.stButton > button {
|
||||
background: transparent;
|
||||
border: 1px solid #ff5f1f;
|
||||
color: #ff5f1f;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stButton > button:hover {
|
||||
background: rgba(255, 95, 31, 0.2);
|
||||
box-shadow: 0 0 15px rgba(255, 95, 31, 0.3);
|
||||
}
|
||||
|
||||
/* Text area styling */
|
||||
.stTextArea textarea {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #333;
|
||||
color: #00ff88;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Success/Error messages */
|
||||
.stSuccess {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border: 1px solid #00ff88;
|
||||
}
|
||||
|
||||
.stError {
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
border: 1px solid #ff4444;
|
||||
}
|
||||
|
||||
/* Metric styling */
|
||||
.css-1xarl3l {
|
||||
background: rgba(20, 20, 30, 0.8);
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
code {
|
||||
color: #0ff !important;
|
||||
background: rgba(0, 255, 255, 0.1) !important;
|
||||
}
|
||||
.stApp { background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); }
|
||||
h1, h2, h3 { color: #58a6ff !important; }
|
||||
.stMetric { background: rgba(22, 27, 34, 0.8); border-radius: 8px; padding: 10px; }
|
||||
.stButton>button { border: 1px solid #30363d; background: #21262d; color: #c9d1d9; }
|
||||
.stButton>button:hover { background: #30363d; border-color: #58a6ff; }
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# Header
|
||||
st.title("📝 METABOLIZER CMS")
|
||||
st.markdown("### Neural Blog Matrix for SecuBox")
|
||||
# Sidebar
|
||||
with st.sidebar:
|
||||
st.image("https://img.icons8.com/fluency/96/blog.png", width=64)
|
||||
st.title("Metabolizer")
|
||||
st.caption("SecuBox Blog CMS")
|
||||
st.divider()
|
||||
|
||||
# Quick stats in columns
|
||||
# Content path status
|
||||
if CONTENT_PATH.exists():
|
||||
post_count = len(list((CONTENT_PATH / "_posts").glob("*.md"))) if (CONTENT_PATH / "_posts").exists() else 0
|
||||
draft_count = len(list((CONTENT_PATH / "_drafts").glob("*.md"))) if (CONTENT_PATH / "_drafts").exists() else 0
|
||||
st.success(f"📁 Content: {post_count} posts, {draft_count} drafts")
|
||||
else:
|
||||
st.warning("📁 Content path not found")
|
||||
|
||||
st.divider()
|
||||
st.caption("v1.0.0 | CyberMind Studio")
|
||||
|
||||
# Main content
|
||||
st.title("📝 Metabolizer CMS")
|
||||
st.markdown("### Blog Content Management System")
|
||||
|
||||
# Status cards
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
|
||||
with col1:
|
||||
st.metric("Status", "ONLINE", delta="Active")
|
||||
|
||||
with col2:
|
||||
st.metric("Posts", "0", delta=None)
|
||||
posts = len(list((CONTENT_PATH / "_posts").glob("*.md"))) if (CONTENT_PATH / "_posts").exists() else 0
|
||||
st.metric("Published", posts)
|
||||
|
||||
with col3:
|
||||
st.metric("Drafts", "0", delta=None)
|
||||
drafts = len(list((CONTENT_PATH / "_drafts").glob("*.md"))) if (CONTENT_PATH / "_drafts").exists() else 0
|
||||
st.metric("Drafts", drafts)
|
||||
|
||||
with col4:
|
||||
st.metric("Pipeline", "Ready")
|
||||
git_ok = (CONTENT_PATH / ".git").exists()
|
||||
st.metric("Git", "Connected" if git_ok else "Not Init")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Navigation info
|
||||
st.info("""
|
||||
**Navigation:** Use the sidebar to access different sections:
|
||||
- **Editor** - Create and edit blog posts with live preview
|
||||
- **Posts** - Manage published posts
|
||||
- **Media** - Upload and manage images
|
||||
- **Settings** - Configure Git and Hexo integration
|
||||
""")
|
||||
# Quick start guide
|
||||
st.subheader("🚀 Quick Start")
|
||||
|
||||
# Quick actions - simplified without switch_page
|
||||
st.subheader("Quick Actions")
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
st.markdown("""
|
||||
Use the **sidebar** on the left to navigate to:
|
||||
- 📝 **1_editor** - Write new posts
|
||||
- 📚 **2_posts** - Manage posts
|
||||
- 🖼️ **3_media** - Media library
|
||||
- ⚙️ **4_settings** - Settings
|
||||
""")
|
||||
with col1:
|
||||
st.markdown("""
|
||||
**Navigation** (use sidebar pages):
|
||||
- **✏️ Editor** - Create new posts with live preview
|
||||
- **📚 Posts** - Manage published posts and drafts
|
||||
- **🖼️ Media** - Upload and manage images
|
||||
- **⚙️ Settings** - Git sync and configuration
|
||||
""")
|
||||
|
||||
with col2:
|
||||
st.markdown("""
|
||||
**Workflow**:
|
||||
1. Write posts in the **Editor**
|
||||
2. Save as draft or publish directly
|
||||
3. Posts sync to **Gitea** repository
|
||||
4. **HexoJS** generates static site
|
||||
5. View blog at `/blog/`
|
||||
""")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Recent activity
|
||||
st.subheader("📋 Recent Posts")
|
||||
|
||||
posts_path = CONTENT_PATH / "_posts"
|
||||
if posts_path.exists():
|
||||
posts = sorted(posts_path.glob("*.md"), reverse=True)[:5]
|
||||
if posts:
|
||||
for post in posts:
|
||||
with st.container():
|
||||
cols = st.columns([4, 1])
|
||||
with cols[0]:
|
||||
st.markdown(f"📄 **{post.stem}**")
|
||||
with cols[1]:
|
||||
st.caption(post.stat().st_mtime)
|
||||
else:
|
||||
st.info("No posts yet. Create your first post in the Editor!")
|
||||
else:
|
||||
st.warning("Content directory not initialized. Go to Settings to set up.")
|
||||
|
||||
# Footer
|
||||
st.divider()
|
||||
st.caption("Metabolizer CMS v1.0 | SecuBox Blog Pipeline")
|
||||
st.caption("Metabolizer CMS | Powered by Streamlit + Gitea + HexoJS")
|
||||
|
||||
@ -1,192 +0,0 @@
|
||||
"""
|
||||
Metabolizer CMS - Markdown Editor with Live Preview
|
||||
"""
|
||||
import streamlit as st
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
st.set_page_config(page_title="Editor - Metabolizer", page_icon="✏️", layout="wide")
|
||||
|
||||
# Paths
|
||||
CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content'))
|
||||
POSTS_PATH = CONTENT_PATH / "_posts"
|
||||
DRAFTS_PATH = CONTENT_PATH / "_drafts"
|
||||
|
||||
# Ensure directories exist
|
||||
POSTS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
DRAFTS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
st.title("✏️ Post Editor")
|
||||
|
||||
# Initialize session state
|
||||
if 'post_content' not in st.session_state:
|
||||
st.session_state.post_content = ""
|
||||
if 'post_title' not in st.session_state:
|
||||
st.session_state.post_title = ""
|
||||
|
||||
# Two-column layout: Editor | Preview
|
||||
col_edit, col_preview = st.columns(2)
|
||||
|
||||
with col_edit:
|
||||
st.subheader("Editor")
|
||||
|
||||
# Front Matter Section
|
||||
with st.expander("📋 Front Matter", expanded=True):
|
||||
title = st.text_input("Title", value=st.session_state.post_title, key="title_input")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
date = st.date_input("Date", value=datetime.now())
|
||||
with col2:
|
||||
time = st.time_input("Time", value=datetime.now().time())
|
||||
|
||||
categories = st.multiselect(
|
||||
"Categories",
|
||||
["Security", "Tutorial", "News", "Tech", "Review", "Guide"],
|
||||
default=[]
|
||||
)
|
||||
|
||||
tags = st.text_input("Tags (comma-separated)", placeholder="linux, security, howto")
|
||||
|
||||
excerpt = st.text_area("Excerpt", height=60, placeholder="Brief summary of the post...")
|
||||
|
||||
# Markdown Content
|
||||
st.markdown("**Content (Markdown)**")
|
||||
content = st.text_area(
|
||||
"content",
|
||||
value=st.session_state.post_content,
|
||||
height=400,
|
||||
placeholder="""Write your post in Markdown...
|
||||
|
||||
## Heading
|
||||
|
||||
Regular paragraph with **bold** and *italic* text.
|
||||
|
||||
```python
|
||||
# Code block
|
||||
print("Hello, World!")
|
||||
```
|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
|
||||
> Blockquote
|
||||
|
||||
[Link text](https://example.com)
|
||||
|
||||

|
||||
""",
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
|
||||
# Update session state
|
||||
st.session_state.post_content = content
|
||||
st.session_state.post_title = title
|
||||
|
||||
with col_preview:
|
||||
st.subheader("Preview")
|
||||
|
||||
if title:
|
||||
st.markdown(f"# {title}")
|
||||
st.caption(f"📅 {date} | 🏷️ {', '.join(categories) if categories else 'Uncategorized'}")
|
||||
if excerpt:
|
||||
st.info(excerpt)
|
||||
st.divider()
|
||||
|
||||
if content:
|
||||
st.markdown(content)
|
||||
else:
|
||||
st.markdown("*Start typing to see preview...*")
|
||||
|
||||
# Actions
|
||||
st.divider()
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
|
||||
def generate_filename(title, date):
|
||||
"""Generate Hexo-compatible filename"""
|
||||
slug = title.lower().replace(" ", "-").replace("'", "")
|
||||
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
||||
return f"{date}-{slug}.md"
|
||||
|
||||
def generate_frontmatter(title, date, time, categories, tags, excerpt):
|
||||
"""Generate YAML front matter without yaml module"""
|
||||
lines = ["---"]
|
||||
lines.append(f"title: {title}")
|
||||
lines.append(f"date: {date} {time.strftime('%H:%M:%S')}")
|
||||
|
||||
if categories:
|
||||
lines.append(f"categories: [{', '.join(categories)}]")
|
||||
else:
|
||||
lines.append("categories: []")
|
||||
|
||||
if tags:
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
lines.append(f"tags: [{', '.join(tag_list)}]")
|
||||
else:
|
||||
lines.append("tags: []")
|
||||
|
||||
if excerpt:
|
||||
lines.append(f"excerpt: \"{excerpt}\"")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
def save_post(path, title, date, time, categories, tags, excerpt, content):
|
||||
"""Save post to file"""
|
||||
filename = generate_filename(title, date)
|
||||
filepath = path / filename
|
||||
|
||||
frontmatter = generate_frontmatter(title, date, time, categories, tags, excerpt)
|
||||
full_content = frontmatter + content
|
||||
|
||||
filepath.write_text(full_content)
|
||||
return filepath
|
||||
|
||||
def git_commit_push(message):
|
||||
"""Commit and push to Gitea"""
|
||||
try:
|
||||
subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'commit', '-m', message], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'push', 'origin', 'master'], cwd=CONTENT_PATH, capture_output=True)
|
||||
except:
|
||||
pass
|
||||
|
||||
with col1:
|
||||
if st.button("💾 Save Draft", use_container_width=True):
|
||||
if title and content:
|
||||
filepath = save_post(DRAFTS_PATH, title, date, time, categories, tags, excerpt, content)
|
||||
st.success(f"Draft saved: {filepath.name}")
|
||||
else:
|
||||
st.error("Title and content required")
|
||||
|
||||
with col2:
|
||||
if st.button("📤 Publish", use_container_width=True, type="primary"):
|
||||
if title and content:
|
||||
filepath = save_post(POSTS_PATH, title, date, time, categories, tags, excerpt, content)
|
||||
|
||||
# Commit and push
|
||||
with st.spinner("Publishing..."):
|
||||
git_commit_push(f"Add post: {title}")
|
||||
|
||||
st.success(f"Published: {filepath.name}")
|
||||
st.info("Post saved to repository")
|
||||
else:
|
||||
st.error("Title and content required")
|
||||
|
||||
with col3:
|
||||
if st.button("🔄 Sync", use_container_width=True):
|
||||
with st.spinner("Syncing..."):
|
||||
try:
|
||||
subprocess.run(['git', 'pull', 'origin', 'master'], cwd=CONTENT_PATH, capture_output=True)
|
||||
st.success("Synced!")
|
||||
except:
|
||||
st.error("Sync failed")
|
||||
|
||||
with col4:
|
||||
if st.button("🗑️ Clear", use_container_width=True):
|
||||
st.session_state.post_content = ""
|
||||
st.session_state.post_title = ""
|
||||
st.rerun()
|
||||
@ -0,0 +1,130 @@
|
||||
"""
|
||||
Metabolizer CMS - Markdown Editor
|
||||
"""
|
||||
import streamlit as st
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content'))
|
||||
POSTS_PATH = CONTENT_PATH / "_posts"
|
||||
DRAFTS_PATH = CONTENT_PATH / "_drafts"
|
||||
|
||||
st.set_page_config(page_title="Editor", page_icon="✏️", layout="wide")
|
||||
|
||||
# Ensure directories exist
|
||||
POSTS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
DRAFTS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
st.title("✏️ Post Editor")
|
||||
|
||||
# Session state
|
||||
if 'content' not in st.session_state:
|
||||
st.session_state.content = ""
|
||||
if 'title' not in st.session_state:
|
||||
st.session_state.title = ""
|
||||
|
||||
# Layout
|
||||
col_edit, col_preview = st.columns(2)
|
||||
|
||||
with col_edit:
|
||||
st.subheader("Write")
|
||||
|
||||
# Metadata
|
||||
title = st.text_input("Title", value=st.session_state.title)
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
date = st.date_input("Date", value=datetime.now())
|
||||
with col2:
|
||||
categories = st.multiselect("Categories",
|
||||
["Tech", "Security", "Tutorial", "News", "Review"], default=[])
|
||||
|
||||
tags = st.text_input("Tags", placeholder="tag1, tag2, tag3")
|
||||
excerpt = st.text_area("Excerpt", height=60, placeholder="Brief description...")
|
||||
|
||||
# Content
|
||||
content = st.text_area("Content (Markdown)", value=st.session_state.content,
|
||||
height=350, placeholder="Write your post here...")
|
||||
|
||||
st.session_state.content = content
|
||||
st.session_state.title = title
|
||||
|
||||
with col_preview:
|
||||
st.subheader("Preview")
|
||||
|
||||
if title:
|
||||
st.markdown(f"# {title}")
|
||||
st.caption(f"📅 {date} | 🏷️ {', '.join(categories) if categories else 'Uncategorized'}")
|
||||
if excerpt:
|
||||
st.info(excerpt)
|
||||
st.divider()
|
||||
|
||||
if content:
|
||||
st.markdown(content)
|
||||
else:
|
||||
st.markdown("*Start typing to see preview...*")
|
||||
|
||||
# Actions
|
||||
st.divider()
|
||||
|
||||
def make_slug(text):
|
||||
return "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
|
||||
|
||||
def make_frontmatter():
|
||||
lines = ["---", f"title: \"{title}\"", f"date: {date}"]
|
||||
if categories:
|
||||
lines.append(f"categories: [{', '.join(categories)}]")
|
||||
if tags:
|
||||
lines.append(f"tags: [{', '.join(t.strip() for t in tags.split(','))}]")
|
||||
if excerpt:
|
||||
lines.append(f"excerpt: \"{excerpt}\"")
|
||||
lines.extend(["---", ""])
|
||||
return "\n".join(lines)
|
||||
|
||||
def save_file(path):
|
||||
filename = f"{date}-{make_slug(title)}.md"
|
||||
filepath = path / filename
|
||||
filepath.write_text(make_frontmatter() + content)
|
||||
return filepath
|
||||
|
||||
def git_push(msg):
|
||||
try:
|
||||
subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'commit', '-m', msg], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'push'], cwd=CONTENT_PATH, capture_output=True)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
|
||||
with col1:
|
||||
if st.button("💾 Save Draft", use_container_width=True):
|
||||
if title and content:
|
||||
fp = save_file(DRAFTS_PATH)
|
||||
st.success(f"Saved: {fp.name}")
|
||||
else:
|
||||
st.error("Title and content required")
|
||||
|
||||
with col2:
|
||||
if st.button("📤 Publish", use_container_width=True, type="primary"):
|
||||
if title and content:
|
||||
fp = save_file(POSTS_PATH)
|
||||
with st.spinner("Publishing..."):
|
||||
git_push(f"Add: {title}")
|
||||
st.success(f"Published: {fp.name}")
|
||||
else:
|
||||
st.error("Title and content required")
|
||||
|
||||
with col3:
|
||||
if st.button("🔄 Sync", use_container_width=True):
|
||||
subprocess.run(['git', 'pull'], cwd=CONTENT_PATH, capture_output=True)
|
||||
st.success("Synced!")
|
||||
|
||||
with col4:
|
||||
if st.button("🗑️ Clear", use_container_width=True):
|
||||
st.session_state.content = ""
|
||||
st.session_state.title = ""
|
||||
st.rerun()
|
||||
@ -1,173 +0,0 @@
|
||||
"""
|
||||
Metabolizer CMS - Post Management
|
||||
"""
|
||||
import streamlit as st
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
|
||||
st.set_page_config(page_title="Posts - Metabolizer", page_icon="📚", layout="wide")
|
||||
|
||||
# Paths
|
||||
CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content'))
|
||||
POSTS_PATH = CONTENT_PATH / "_posts"
|
||||
DRAFTS_PATH = CONTENT_PATH / "_drafts"
|
||||
|
||||
st.title("📚 Post Management")
|
||||
|
||||
def parse_frontmatter(filepath):
|
||||
"""Parse YAML front matter from markdown file (without yaml module)"""
|
||||
try:
|
||||
content = filepath.read_text()
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm_text = parts[1].strip()
|
||||
body = parts[2].strip()
|
||||
|
||||
# Simple parsing
|
||||
fm = {}
|
||||
for line in fm_text.split('\n'):
|
||||
if ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
# Handle arrays
|
||||
if value.startswith('[') and value.endswith(']'):
|
||||
value = [v.strip().strip('"\'') for v in value[1:-1].split(',') if v.strip()]
|
||||
elif value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
fm[key] = value
|
||||
return fm, body
|
||||
except Exception as e:
|
||||
pass
|
||||
return {}, ""
|
||||
|
||||
def get_posts(path):
|
||||
"""Get all posts from a directory"""
|
||||
posts = []
|
||||
if path.exists():
|
||||
for f in sorted(path.glob("*.md"), reverse=True):
|
||||
fm, body = parse_frontmatter(f)
|
||||
posts.append({
|
||||
'filename': f.name,
|
||||
'path': f,
|
||||
'title': fm.get('title', f.stem),
|
||||
'date': fm.get('date', ''),
|
||||
'categories': fm.get('categories', []),
|
||||
'tags': fm.get('tags', []),
|
||||
'excerpt': fm.get('excerpt', body[:150] + '...' if len(body) > 150 else body),
|
||||
'body': body
|
||||
})
|
||||
return posts
|
||||
|
||||
def git_commit_push(message):
|
||||
"""Commit and push to Gitea"""
|
||||
try:
|
||||
subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'commit', '-m', message], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'push', 'origin', 'master'], cwd=CONTENT_PATH, capture_output=True)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Tabs for Published and Drafts
|
||||
tab1, tab2 = st.tabs(["📰 Published", "📝 Drafts"])
|
||||
|
||||
with tab1:
|
||||
posts = get_posts(POSTS_PATH)
|
||||
|
||||
if not posts:
|
||||
st.info("No published posts yet. Create your first post in the Editor!")
|
||||
else:
|
||||
st.write(f"**{len(posts)} published posts**")
|
||||
|
||||
for post in posts:
|
||||
with st.expander(f"📄 {post['title']}", expanded=False):
|
||||
col1, col2 = st.columns([3, 1])
|
||||
|
||||
with col1:
|
||||
st.caption(f"📅 {post['date']}")
|
||||
if post['categories']:
|
||||
cats = post['categories'] if isinstance(post['categories'], list) else [post['categories']]
|
||||
st.caption(f"📁 {', '.join(cats)}")
|
||||
if post['tags']:
|
||||
tags = post['tags'] if isinstance(post['tags'], list) else [post['tags']]
|
||||
st.caption(f"🏷️ {', '.join(tags)}")
|
||||
st.markdown(post['excerpt'])
|
||||
|
||||
with col2:
|
||||
if st.button("✏️ Edit", key=f"edit_{post['filename']}"):
|
||||
st.session_state.post_title = post['title']
|
||||
st.session_state.post_content = post['body']
|
||||
st.switch_page("pages/1_editor.py")
|
||||
|
||||
if st.button("🗑️ Delete", key=f"del_{post['filename']}"):
|
||||
post['path'].unlink()
|
||||
git_commit_push(f"Delete post: {post['title']}")
|
||||
st.success(f"Deleted: {post['filename']}")
|
||||
st.rerun()
|
||||
|
||||
if st.button("📥 Unpublish", key=f"unpub_{post['filename']}"):
|
||||
DRAFTS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
new_path = DRAFTS_PATH / post['filename']
|
||||
post['path'].rename(new_path)
|
||||
git_commit_push(f"Unpublish: {post['title']}")
|
||||
st.success(f"Moved to drafts")
|
||||
st.rerun()
|
||||
|
||||
with tab2:
|
||||
drafts = get_posts(DRAFTS_PATH)
|
||||
|
||||
if not drafts:
|
||||
st.info("No drafts. Save a draft from the Editor!")
|
||||
else:
|
||||
st.write(f"**{len(drafts)} drafts**")
|
||||
|
||||
for draft in drafts:
|
||||
with st.expander(f"📝 {draft['title']}", expanded=False):
|
||||
col1, col2 = st.columns([3, 1])
|
||||
|
||||
with col1:
|
||||
st.caption(f"📅 {draft['date']}")
|
||||
st.markdown(draft['excerpt'])
|
||||
|
||||
with col2:
|
||||
if st.button("✏️ Edit", key=f"edit_draft_{draft['filename']}"):
|
||||
st.session_state.post_title = draft['title']
|
||||
st.session_state.post_content = draft['body']
|
||||
st.switch_page("pages/1_editor.py")
|
||||
|
||||
if st.button("📤 Publish", key=f"pub_{draft['filename']}"):
|
||||
POSTS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
new_path = POSTS_PATH / draft['filename']
|
||||
draft['path'].rename(new_path)
|
||||
git_commit_push(f"Publish: {draft['title']}")
|
||||
st.success(f"Published!")
|
||||
st.rerun()
|
||||
|
||||
if st.button("🗑️ Delete", key=f"del_draft_{draft['filename']}"):
|
||||
draft['path'].unlink()
|
||||
st.success(f"Deleted")
|
||||
st.rerun()
|
||||
|
||||
# Sync action
|
||||
st.divider()
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
if st.button("🔄 Sync from Git", use_container_width=True):
|
||||
with st.spinner("Syncing..."):
|
||||
try:
|
||||
subprocess.run(['git', 'pull', 'origin', 'master'], cwd=CONTENT_PATH, capture_output=True)
|
||||
st.success("Synced!")
|
||||
st.rerun()
|
||||
except:
|
||||
st.error("Sync failed")
|
||||
|
||||
with col2:
|
||||
if st.button("📤 Push to Git", use_container_width=True):
|
||||
with st.spinner("Pushing..."):
|
||||
git_commit_push("Update posts")
|
||||
st.success("Pushed!")
|
||||
@ -0,0 +1,100 @@
|
||||
"""
|
||||
Metabolizer CMS - Post Management
|
||||
"""
|
||||
import streamlit as st
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content'))
|
||||
POSTS_PATH = CONTENT_PATH / "_posts"
|
||||
DRAFTS_PATH = CONTENT_PATH / "_drafts"
|
||||
|
||||
st.set_page_config(page_title="Posts", page_icon="📚", layout="wide")
|
||||
st.title("📚 Post Management")
|
||||
|
||||
def parse_post(filepath):
|
||||
"""Parse markdown file with frontmatter"""
|
||||
try:
|
||||
text = filepath.read_text()
|
||||
if text.startswith("---"):
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
meta = {}
|
||||
for line in parts[1].strip().split('\n'):
|
||||
if ':' in line:
|
||||
k, v = line.split(':', 1)
|
||||
meta[k.strip()] = v.strip().strip('"')
|
||||
return meta, parts[2].strip()
|
||||
except:
|
||||
pass
|
||||
return {'title': filepath.stem}, ""
|
||||
|
||||
def git_commit(msg):
|
||||
subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'commit', '-m', msg], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'push'], cwd=CONTENT_PATH, capture_output=True)
|
||||
|
||||
# Tabs
|
||||
tab1, tab2 = st.tabs(["📰 Published", "📝 Drafts"])
|
||||
|
||||
with tab1:
|
||||
if POSTS_PATH.exists():
|
||||
posts = sorted(POSTS_PATH.glob("*.md"), reverse=True)
|
||||
st.caption(f"{len(posts)} published posts")
|
||||
|
||||
for post in posts:
|
||||
meta, body = parse_post(post)
|
||||
with st.expander(f"📄 {meta.get('title', post.stem)}"):
|
||||
col1, col2 = st.columns([3, 1])
|
||||
with col1:
|
||||
st.caption(f"📅 {meta.get('date', 'Unknown')}")
|
||||
st.markdown(body[:200] + "..." if len(body) > 200 else body)
|
||||
with col2:
|
||||
if st.button("🗑️ Delete", key=f"del_{post.name}"):
|
||||
post.unlink()
|
||||
git_commit(f"Delete: {post.name}")
|
||||
st.rerun()
|
||||
if st.button("📥 Unpublish", key=f"unpub_{post.name}"):
|
||||
DRAFTS_PATH.mkdir(exist_ok=True)
|
||||
post.rename(DRAFTS_PATH / post.name)
|
||||
git_commit(f"Unpublish: {post.name}")
|
||||
st.rerun()
|
||||
else:
|
||||
st.info("No published posts")
|
||||
|
||||
with tab2:
|
||||
if DRAFTS_PATH.exists():
|
||||
drafts = sorted(DRAFTS_PATH.glob("*.md"), reverse=True)
|
||||
st.caption(f"{len(drafts)} drafts")
|
||||
|
||||
for draft in drafts:
|
||||
meta, body = parse_post(draft)
|
||||
with st.expander(f"📝 {meta.get('title', draft.stem)}"):
|
||||
col1, col2 = st.columns([3, 1])
|
||||
with col1:
|
||||
st.markdown(body[:200] + "..." if len(body) > 200 else body)
|
||||
with col2:
|
||||
if st.button("📤 Publish", key=f"pub_{draft.name}"):
|
||||
POSTS_PATH.mkdir(exist_ok=True)
|
||||
draft.rename(POSTS_PATH / draft.name)
|
||||
git_commit(f"Publish: {draft.name}")
|
||||
st.rerun()
|
||||
if st.button("🗑️ Delete", key=f"deld_{draft.name}"):
|
||||
draft.unlink()
|
||||
st.rerun()
|
||||
else:
|
||||
st.info("No drafts")
|
||||
|
||||
# Sync
|
||||
st.divider()
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
if st.button("🔄 Pull from Git", use_container_width=True):
|
||||
subprocess.run(['git', 'pull'], cwd=CONTENT_PATH, capture_output=True)
|
||||
st.success("Synced!")
|
||||
st.rerun()
|
||||
with col2:
|
||||
if st.button("📤 Push to Git", use_container_width=True):
|
||||
git_commit("Update posts")
|
||||
st.success("Pushed!")
|
||||
@ -1,119 +0,0 @@
|
||||
"""
|
||||
Metabolizer CMS - Media Library
|
||||
"""
|
||||
import streamlit as st
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import os
|
||||
import shutil
|
||||
|
||||
st.set_page_config(page_title="Media - Metabolizer", page_icon="🖼️", layout="wide")
|
||||
|
||||
# Paths
|
||||
CONTENT_PATH = Path("/srv/metabolizer/content")
|
||||
MEDIA_PATH = CONTENT_PATH / "images"
|
||||
|
||||
# Ensure directory exists
|
||||
MEDIA_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
st.title("🖼️ Media Library")
|
||||
|
||||
def git_commit_push(message):
|
||||
"""Commit and push to Gitea"""
|
||||
os.chdir(CONTENT_PATH)
|
||||
subprocess.run(['git', 'add', '-A'], capture_output=True)
|
||||
subprocess.run(['git', 'commit', '-m', message], capture_output=True)
|
||||
subprocess.run(['git', 'push', 'origin', 'main'], capture_output=True)
|
||||
|
||||
def get_media_files():
|
||||
"""Get all media files"""
|
||||
files = []
|
||||
if MEDIA_PATH.exists():
|
||||
for ext in ['*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.svg']:
|
||||
files.extend(MEDIA_PATH.glob(ext))
|
||||
files.extend(MEDIA_PATH.glob(ext.upper()))
|
||||
return sorted(files, key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
|
||||
# Upload Section
|
||||
st.subheader("📤 Upload")
|
||||
|
||||
uploaded_files = st.file_uploader(
|
||||
"Choose images",
|
||||
type=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'],
|
||||
accept_multiple_files=True
|
||||
)
|
||||
|
||||
if uploaded_files:
|
||||
for uploaded_file in uploaded_files:
|
||||
save_path = MEDIA_PATH / uploaded_file.name
|
||||
|
||||
# Save file
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(uploaded_file.getbuffer())
|
||||
|
||||
st.success(f"Uploaded: {uploaded_file.name}")
|
||||
|
||||
# Show markdown code
|
||||
st.code(f"")
|
||||
|
||||
# Commit to git
|
||||
git_commit_push(f"Add {len(uploaded_files)} media files")
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# Media Gallery
|
||||
st.subheader("📁 Library")
|
||||
|
||||
media_files = get_media_files()
|
||||
|
||||
if not media_files:
|
||||
st.info("No media files yet. Upload some images above!")
|
||||
else:
|
||||
st.write(f"**{len(media_files)} files**")
|
||||
|
||||
# Display in grid
|
||||
cols = st.columns(4)
|
||||
|
||||
for idx, media_file in enumerate(media_files):
|
||||
col = cols[idx % 4]
|
||||
|
||||
with col:
|
||||
# Display image
|
||||
try:
|
||||
st.image(str(media_file), use_container_width=True)
|
||||
except:
|
||||
st.write(f"📄 {media_file.name}")
|
||||
|
||||
# File info
|
||||
size = media_file.stat().st_size
|
||||
size_str = f"{size / 1024:.1f} KB" if size < 1024 * 1024 else f"{size / 1024 / 1024:.1f} MB"
|
||||
st.caption(f"{media_file.name} ({size_str})")
|
||||
|
||||
# Copy markdown button
|
||||
markdown_code = f""
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
if st.button("📋 Copy", key=f"copy_{media_file.name}"):
|
||||
st.code(markdown_code)
|
||||
st.info("Copy the code above")
|
||||
|
||||
with col2:
|
||||
if st.button("🗑️", key=f"del_{media_file.name}"):
|
||||
media_file.unlink()
|
||||
git_commit_push(f"Delete: {media_file.name}")
|
||||
st.rerun()
|
||||
|
||||
# Sync action
|
||||
st.divider()
|
||||
if st.button("🔄 Sync Media to Blog", use_container_width=True):
|
||||
with st.spinner("Syncing..."):
|
||||
result = subprocess.run(
|
||||
['/usr/sbin/metabolizerctl', 'build'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
st.success("Media synced to blog!")
|
||||
else:
|
||||
st.error("Sync failed")
|
||||
@ -0,0 +1,75 @@
|
||||
"""
|
||||
Metabolizer CMS - Media Library
|
||||
"""
|
||||
import streamlit as st
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content'))
|
||||
MEDIA_PATH = CONTENT_PATH / "images"
|
||||
|
||||
st.set_page_config(page_title="Media", page_icon="🖼️", layout="wide")
|
||||
st.title("🖼️ Media Library")
|
||||
|
||||
MEDIA_PATH.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def git_commit(msg):
|
||||
subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'commit', '-m', msg], cwd=CONTENT_PATH, capture_output=True)
|
||||
subprocess.run(['git', 'push'], cwd=CONTENT_PATH, capture_output=True)
|
||||
|
||||
# Upload
|
||||
st.subheader("📤 Upload")
|
||||
uploaded = st.file_uploader("Choose images", type=['png', 'jpg', 'jpeg', 'gif', 'webp'], accept_multiple_files=True)
|
||||
|
||||
if uploaded:
|
||||
for f in uploaded:
|
||||
save_path = MEDIA_PATH / f.name
|
||||
save_path.write_bytes(f.getbuffer())
|
||||
st.success(f"Uploaded: {f.name}")
|
||||
st.code(f"")
|
||||
git_commit(f"Add {len(uploaded)} images")
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# Gallery
|
||||
st.subheader("📁 Library")
|
||||
|
||||
images = list(MEDIA_PATH.glob("*"))
|
||||
images = [i for i in images if i.suffix.lower() in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']]
|
||||
|
||||
if images:
|
||||
st.caption(f"{len(images)} files")
|
||||
cols = st.columns(4)
|
||||
|
||||
for idx, img in enumerate(sorted(images, key=lambda x: x.stat().st_mtime, reverse=True)):
|
||||
col = cols[idx % 4]
|
||||
with col:
|
||||
try:
|
||||
st.image(str(img), use_container_width=True)
|
||||
except:
|
||||
st.markdown(f"📄 {img.name}")
|
||||
|
||||
size = img.stat().st_size
|
||||
size_str = f"{size/1024:.1f}KB" if size < 1024*1024 else f"{size/1024/1024:.1f}MB"
|
||||
st.caption(f"{img.name} ({size_str})")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
if st.button("📋", key=f"copy_{img.name}", help="Copy markdown"):
|
||||
st.code(f"")
|
||||
with col2:
|
||||
if st.button("🗑️", key=f"del_{img.name}", help="Delete"):
|
||||
img.unlink()
|
||||
git_commit(f"Delete: {img.name}")
|
||||
st.rerun()
|
||||
else:
|
||||
st.info("No images yet. Upload some above!")
|
||||
|
||||
# Sync
|
||||
st.divider()
|
||||
if st.button("🔄 Sync Media", use_container_width=True):
|
||||
git_commit("Sync media")
|
||||
st.success("Media synced!")
|
||||
@ -1,137 +0,0 @@
|
||||
"""
|
||||
Metabolizer CMS - Settings
|
||||
"""
|
||||
import streamlit as st
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
st.set_page_config(page_title="Settings - Metabolizer", page_icon="⚙️", layout="wide")
|
||||
|
||||
st.title("⚙️ Settings")
|
||||
|
||||
# Paths
|
||||
CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content'))
|
||||
GITEA_URL = os.environ.get('GITEA_URL', 'http://host.containers.internal:3000')
|
||||
|
||||
def git_command(args, cwd=None):
|
||||
"""Run git command"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git'] + args,
|
||||
cwd=cwd or CONTENT_PATH,
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
return False, "", str(e)
|
||||
|
||||
# Pipeline Status
|
||||
st.subheader("📊 Pipeline Status")
|
||||
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
st.metric("Gitea", "EXTERNAL", delta="Host")
|
||||
|
||||
with col2:
|
||||
st.metric("Streamlit", "RUNNING", delta="OK")
|
||||
|
||||
with col3:
|
||||
st.metric("HexoJS", "EXTERNAL", delta="Host")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Content Repository
|
||||
st.subheader("📁 Content Repository")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.text_input("Content Path", value=str(CONTENT_PATH), disabled=True)
|
||||
|
||||
# Check if git repo exists
|
||||
if (CONTENT_PATH / '.git').exists():
|
||||
success, stdout, _ = git_command(['remote', '-v'])
|
||||
if success and stdout:
|
||||
remote = stdout.split('\n')[0] if stdout else "No remote"
|
||||
st.text_input("Remote", value=remote.split()[1] if '\t' in remote or ' ' in remote else remote, disabled=True)
|
||||
|
||||
success, stdout, _ = git_command(['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
st.text_input("Branch", value=stdout.strip() if success else "unknown", disabled=True)
|
||||
else:
|
||||
st.warning("Content directory is not a git repository")
|
||||
|
||||
with col2:
|
||||
# Count posts
|
||||
posts_path = CONTENT_PATH / '_posts'
|
||||
post_count = len(list(posts_path.glob('*.md'))) if posts_path.exists() else 0
|
||||
st.metric("Posts", post_count)
|
||||
|
||||
drafts_path = CONTENT_PATH / '_drafts'
|
||||
draft_count = len(list(drafts_path.glob('*.md'))) if drafts_path.exists() else 0
|
||||
st.metric("Drafts", draft_count)
|
||||
|
||||
# Git Operations
|
||||
st.subheader("🔗 Git Operations")
|
||||
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
if st.button("🔄 Pull Latest", use_container_width=True):
|
||||
with st.spinner("Pulling..."):
|
||||
success, stdout, stderr = git_command(['pull', 'origin', 'master'])
|
||||
if success:
|
||||
st.success("Pulled latest changes")
|
||||
else:
|
||||
st.error(f"Pull failed: {stderr}")
|
||||
|
||||
with col2:
|
||||
if st.button("📊 Git Status", use_container_width=True):
|
||||
success, stdout, stderr = git_command(['status', '--short'])
|
||||
if stdout:
|
||||
st.code(stdout)
|
||||
else:
|
||||
st.info("Working tree clean")
|
||||
|
||||
with col3:
|
||||
if st.button("📤 Push Changes", use_container_width=True):
|
||||
with st.spinner("Pushing..."):
|
||||
success, stdout, stderr = git_command(['push', 'origin', 'master'])
|
||||
if success:
|
||||
st.success("Pushed changes")
|
||||
else:
|
||||
st.error(f"Push failed: {stderr}")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Initialize Repository
|
||||
st.subheader("🆕 Initialize Content Repository")
|
||||
|
||||
with st.expander("Setup New Repository"):
|
||||
repo_url = st.text_input("Gitea Repository URL", placeholder="http://host:3000/user/blog-content.git")
|
||||
|
||||
if st.button("Clone Repository", use_container_width=True):
|
||||
if repo_url:
|
||||
with st.spinner("Cloning..."):
|
||||
CONTENT_PATH.mkdir(parents=True, exist_ok=True)
|
||||
success, stdout, stderr = git_command(['clone', repo_url, str(CONTENT_PATH)], cwd='/srv')
|
||||
if success:
|
||||
st.success("Repository cloned!")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(f"Clone failed: {stderr}")
|
||||
else:
|
||||
st.warning("Enter a repository URL")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Environment Info
|
||||
with st.expander("🔧 Debug: Environment"):
|
||||
st.json({
|
||||
"CONTENT_PATH": str(CONTENT_PATH),
|
||||
"GITEA_URL": GITEA_URL,
|
||||
"CWD": os.getcwd(),
|
||||
"PATH": os.environ.get('PATH', ''),
|
||||
})
|
||||
@ -0,0 +1,141 @@
|
||||
"""
|
||||
Metabolizer CMS - Settings
|
||||
"""
|
||||
import streamlit as st
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content'))
|
||||
GITEA_URL = os.environ.get('GITEA_URL', 'http://192.168.255.1:3000')
|
||||
|
||||
st.set_page_config(page_title="Settings", page_icon="⚙️", layout="wide")
|
||||
st.title("⚙️ Settings")
|
||||
|
||||
def run_git(args):
|
||||
try:
|
||||
r = subprocess.run(['git'] + args, cwd=CONTENT_PATH, capture_output=True, text=True)
|
||||
return r.returncode == 0, r.stdout, r.stderr
|
||||
except Exception as e:
|
||||
return False, "", str(e)
|
||||
|
||||
# Status
|
||||
st.subheader("📊 Status")
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
st.metric("Gitea", "External", delta="Host :3000")
|
||||
with col2:
|
||||
st.metric("Streamlit", "Running", delta="OK")
|
||||
with col3:
|
||||
st.metric("Content", "Local", delta=str(CONTENT_PATH))
|
||||
|
||||
st.divider()
|
||||
|
||||
# Repository
|
||||
st.subheader("📁 Content Repository")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.text_input("Content Path", value=str(CONTENT_PATH), disabled=True)
|
||||
|
||||
if (CONTENT_PATH / ".git").exists():
|
||||
ok, out, _ = run_git(['remote', '-v'])
|
||||
if ok and out:
|
||||
remote = out.split('\n')[0].split()[1] if out else "None"
|
||||
st.text_input("Remote", value=remote, disabled=True)
|
||||
|
||||
ok, out, _ = run_git(['branch', '--show-current'])
|
||||
st.text_input("Branch", value=out.strip() if ok else "unknown", disabled=True)
|
||||
|
||||
st.success("✅ Git repository initialized")
|
||||
else:
|
||||
st.warning("⚠️ Not a git repository")
|
||||
|
||||
with col2:
|
||||
posts = len(list((CONTENT_PATH / "_posts").glob("*.md"))) if (CONTENT_PATH / "_posts").exists() else 0
|
||||
drafts = len(list((CONTENT_PATH / "_drafts").glob("*.md"))) if (CONTENT_PATH / "_drafts").exists() else 0
|
||||
images = len(list((CONTENT_PATH / "images").glob("*"))) if (CONTENT_PATH / "images").exists() else 0
|
||||
|
||||
st.metric("Posts", posts)
|
||||
st.metric("Drafts", drafts)
|
||||
st.metric("Images", images)
|
||||
|
||||
st.divider()
|
||||
|
||||
# Git Operations
|
||||
st.subheader("🔗 Git Operations")
|
||||
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
if st.button("🔄 Pull", use_container_width=True):
|
||||
ok, out, err = run_git(['pull'])
|
||||
if ok:
|
||||
st.success("Pulled!")
|
||||
else:
|
||||
st.error(err or "Pull failed")
|
||||
|
||||
with col2:
|
||||
if st.button("📊 Status", use_container_width=True):
|
||||
ok, out, _ = run_git(['status', '--short'])
|
||||
if out:
|
||||
st.code(out)
|
||||
else:
|
||||
st.info("Working tree clean")
|
||||
|
||||
with col3:
|
||||
if st.button("📤 Push", use_container_width=True):
|
||||
run_git(['add', '-A'])
|
||||
run_git(['commit', '-m', 'Update from CMS'])
|
||||
ok, _, err = run_git(['push'])
|
||||
if ok:
|
||||
st.success("Pushed!")
|
||||
else:
|
||||
st.error(err or "Push failed")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Initialize Repository
|
||||
st.subheader("🆕 Initialize Repository")
|
||||
|
||||
with st.expander("Clone from Gitea"):
|
||||
repo_url = st.text_input("Repository URL", placeholder=f"{GITEA_URL}/user/blog-content.git")
|
||||
|
||||
if st.button("Clone", use_container_width=True):
|
||||
if repo_url:
|
||||
with st.spinner("Cloning..."):
|
||||
if CONTENT_PATH.exists():
|
||||
import shutil
|
||||
shutil.rmtree(CONTENT_PATH, ignore_errors=True)
|
||||
CONTENT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
r = subprocess.run(['git', 'clone', repo_url, str(CONTENT_PATH)], capture_output=True, text=True)
|
||||
if r.returncode == 0:
|
||||
st.success("Cloned!")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(r.stderr)
|
||||
else:
|
||||
st.warning("Enter URL")
|
||||
|
||||
with st.expander("Initialize New"):
|
||||
if st.button("Initialize Empty Repo", use_container_width=True):
|
||||
CONTENT_PATH.mkdir(parents=True, exist_ok=True)
|
||||
(CONTENT_PATH / "_posts").mkdir(exist_ok=True)
|
||||
(CONTENT_PATH / "_drafts").mkdir(exist_ok=True)
|
||||
(CONTENT_PATH / "images").mkdir(exist_ok=True)
|
||||
run_git(['init'])
|
||||
st.success("Initialized!")
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# Debug
|
||||
with st.expander("🔧 Debug"):
|
||||
st.json({
|
||||
"CONTENT_PATH": str(CONTENT_PATH),
|
||||
"GITEA_URL": GITEA_URL,
|
||||
"PATH": os.environ.get('PATH', ''),
|
||||
"git_exists": (CONTENT_PATH / ".git").exists(),
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user