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_NAME:=secubox-app-hexojs
|
||||||
PKG_VERSION:=1.0.0
|
PKG_VERSION:=1.0.0
|
||||||
PKG_RELEASE:=1
|
PKG_RELEASE:=2
|
||||||
PKG_ARCH:=all
|
PKG_ARCH:=all
|
||||||
|
|
||||||
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
|
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 branch 'gh-pages'
|
||||||
option message 'Site updated: {{ now("YYYY-MM-DD HH:mm:ss") }}'
|
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'
|
config theme_config 'theme'
|
||||||
option default_mode 'dark'
|
option default_mode 'dark'
|
||||||
option allow_toggle '1'
|
option allow_toggle '1'
|
||||||
|
|||||||
@ -56,6 +56,15 @@ load_config() {
|
|||||||
deploy_repo="$(uci_get deploy.repo)" || deploy_repo=""
|
deploy_repo="$(uci_get deploy.repo)" || deploy_repo=""
|
||||||
deploy_branch="$(uci_get deploy.branch)" || deploy_branch="gh-pages"
|
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"
|
||||||
ensure_dir "$data_path/sites"
|
ensure_dir "$data_path/sites"
|
||||||
ensure_dir "$data_path/media"
|
ensure_dir "$data_path/media"
|
||||||
@ -98,6 +107,12 @@ Service Commands:
|
|||||||
service-run Run in foreground (for init)
|
service-run Run in foreground (for init)
|
||||||
service-stop Stop service
|
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:
|
Utility:
|
||||||
shell Open shell in container
|
shell Open shell in container
|
||||||
logs View container logs
|
logs View container logs
|
||||||
@ -837,6 +852,194 @@ cmd_service_stop() {
|
|||||||
lxc_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
|
# Main
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
install) shift; cmd_install "$@" ;;
|
install) shift; cmd_install "$@" ;;
|
||||||
@ -894,5 +1097,16 @@ case "${1:-}" in
|
|||||||
service-run) shift; cmd_service_run "$@" ;;
|
service-run) shift; cmd_service_run "$@" ;;
|
||||||
service-stop) shift; cmd_service_stop "$@" ;;
|
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 ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=secubox-app-metabolizer
|
PKG_NAME:=secubox-app-metabolizer
|
||||||
PKG_VERSION:=1.0.0
|
PKG_VERSION:=1.0.0
|
||||||
PKG_RELEASE:=2
|
PKG_RELEASE:=3
|
||||||
PKG_ARCH:=all
|
PKG_ARCH:=all
|
||||||
|
|
||||||
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
|
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Metabolizer CMS - SecuBox Blog Management
|
Metabolizer CMS - SecuBox Blog Management
|
||||||
Main entry point with navigation sidebar
|
Multi-page Streamlit app for blog content management
|
||||||
"""
|
"""
|
||||||
import streamlit as st
|
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(
|
st.set_page_config(
|
||||||
page_title="Metabolizer CMS",
|
page_title="Metabolizer CMS",
|
||||||
@ -11,117 +16,104 @@ st.set_page_config(
|
|||||||
initial_sidebar_state="expanded"
|
initial_sidebar_state="expanded"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cyberpunk styling
|
# Custom CSS
|
||||||
st.markdown("""
|
st.markdown("""
|
||||||
<style>
|
<style>
|
||||||
/* CRT Monitor Effect */
|
.stApp { background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); }
|
||||||
@keyframes scanline {
|
h1, h2, h3 { color: #58a6ff !important; }
|
||||||
0% { transform: translateY(-100%); }
|
.stMetric { background: rgba(22, 27, 34, 0.8); border-radius: 8px; padding: 10px; }
|
||||||
100% { transform: translateY(100%); }
|
.stButton>button { border: 1px solid #30363d; background: #21262d; color: #c9d1d9; }
|
||||||
}
|
.stButton>button:hover { background: #30363d; border-color: #58a6ff; }
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
""", unsafe_allow_html=True)
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
# Header
|
# Sidebar
|
||||||
st.title("📝 METABOLIZER CMS")
|
with st.sidebar:
|
||||||
st.markdown("### Neural Blog Matrix for SecuBox")
|
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)
|
col1, col2, col3, col4 = st.columns(4)
|
||||||
|
|
||||||
with col1:
|
with col1:
|
||||||
st.metric("Status", "ONLINE", delta="Active")
|
st.metric("Status", "ONLINE", delta="Active")
|
||||||
|
|
||||||
with col2:
|
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:
|
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:
|
with col4:
|
||||||
st.metric("Pipeline", "Ready")
|
git_ok = (CONTENT_PATH / ".git").exists()
|
||||||
|
st.metric("Git", "Connected" if git_ok else "Not Init")
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
# Navigation info
|
# Quick start guide
|
||||||
st.info("""
|
st.subheader("🚀 Quick Start")
|
||||||
**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 actions - simplified without switch_page
|
col1, col2 = st.columns(2)
|
||||||
st.subheader("Quick Actions")
|
|
||||||
|
|
||||||
st.markdown("""
|
with col1:
|
||||||
Use the **sidebar** on the left to navigate to:
|
st.markdown("""
|
||||||
- 📝 **1_editor** - Write new posts
|
**Navigation** (use sidebar pages):
|
||||||
- 📚 **2_posts** - Manage posts
|
- **✏️ Editor** - Create new posts with live preview
|
||||||
- 🖼️ **3_media** - Media library
|
- **📚 Posts** - Manage published posts and drafts
|
||||||
- ⚙️ **4_settings** - Settings
|
- **🖼️ 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
|
# Footer
|
||||||
st.divider()
|
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