Add standalone /publiser admin application
Implement a minimal, dependency-free publishing tool to manage news and articles. The app includes: - A Markdown-based block editor with rich-text capabilities. - Image upload and resizing using GD. - Support for drafts, scheduled publishing, and a trash system. - Integrated Basic Auth protection via PHP and .htpasswd. - A global plugin for automatic execution of scheduled posts. The tool reuses core site logic in custom/plugins/publiser-lib.php to ensure consistency between the editor and the live site.
This commit is contained in:
parent
c7ec163a36
commit
b609c8596c
15 changed files with 2301 additions and 8 deletions
726
content/publiser/index.php
Normal file
726
content/publiser/index.php
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
<?php
|
||||
/**
|
||||
* /publiser - standalone publishing tool for content/nyheter and content/artikler.
|
||||
*
|
||||
* This file is served directly by Apache (see the passthrough rule in
|
||||
* content/.htaccess.base) and is deliberately NOT routed through the CMS -
|
||||
* no Context, no templates, no site chrome. It only reuses the pure,
|
||||
* hook-free helpers from app/helpers.php via custom/plugins/publiser-lib.php.
|
||||
*
|
||||
* Access is protected by Basic Auth, checked here in PHP against a bcrypt
|
||||
* .htpasswd (see custom/tools/set-publiser-password.php) rather than via
|
||||
* Apache's AuthUserFile - that directive needs an absolute path that
|
||||
* differs between the podman dev container and cPanel, which this avoids.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../custom/plugins/publiser-lib.php';
|
||||
|
||||
function publiserRequireAuth(): void {
|
||||
$htpasswd = __DIR__ . '/.htpasswd';
|
||||
|
||||
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
|
||||
$pass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
||||
|
||||
// Some SAPIs (LSAPI/CGI/FastCGI) don't populate PHP_AUTH_*; fall back to
|
||||
// parsing the raw header forwarded by .htaccess's RewriteRule.
|
||||
if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/^Basic\s+(.+)$/i', $_SERVER['HTTP_AUTHORIZATION'], $m)) {
|
||||
$decoded = base64_decode($m[1], true) ?: '';
|
||||
if (str_contains($decoded, ':')) {
|
||||
[$user, $pass] = explode(':', $decoded, 2);
|
||||
}
|
||||
}
|
||||
|
||||
$valid = false;
|
||||
if ($user !== null && $pass !== null && is_file($htpasswd)) {
|
||||
foreach (file($htpasswd, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
[$lineUser, $hash] = array_pad(explode(':', $line, 2), 2, '');
|
||||
if ($hash !== '' && hash_equals($lineUser, (string)$user) && password_verify((string)$pass, $hash)) {
|
||||
$valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$valid) {
|
||||
header('WWW-Authenticate: Basic realm="Stopp lidelsen - publisering"');
|
||||
http_response_code(401);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Palogging kreves.";
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
publiserRequireAuth();
|
||||
|
||||
session_start();
|
||||
if (empty($_SESSION['publiser_csrf'])) {
|
||||
$_SESSION['publiser_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
$csrf = $_SESSION['publiser_csrf'];
|
||||
|
||||
// --- small helpers ---------------------------------------------------------
|
||||
|
||||
function h(?string $s): string {
|
||||
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function publiserBaseUrl(): string {
|
||||
return '/publiser/';
|
||||
}
|
||||
|
||||
function publiserListUrl(string $tab, string $section = 'nyheter'): string {
|
||||
return publiserBaseUrl() . '?view=list&tab=' . urlencode($tab) . '§ion=' . urlencode($section);
|
||||
}
|
||||
|
||||
function publiserEditUrl(string $location, string $section, string $folder, array $flags = []): string {
|
||||
$url = publiserBaseUrl() . '?view=edit&location=' . urlencode($location)
|
||||
. '§ion=' . urlencode($section) . '&folder=' . urlencode($folder);
|
||||
foreach ($flags as $k => $v) {
|
||||
$url .= '&' . urlencode($k) . '=' . urlencode((string)$v);
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
function publiserPublicUrl(string $section, string $folder): string {
|
||||
return '/' . rawurlencode($section) . '/' . rawurlencode($folder) . '/';
|
||||
}
|
||||
|
||||
function publiserImageUrl(string $location, string $section, string $folder, string $filename): string {
|
||||
if ($location === 'live') {
|
||||
return '/' . rawurlencode($section) . '/' . rawurlencode($folder) . '/' . rawurlencode($filename);
|
||||
}
|
||||
return publiserBaseUrl() . '?action=image&location=' . urlencode($location)
|
||||
. '§ion=' . urlencode($section) . '&folder=' . urlencode($folder) . '&file=' . urlencode($filename);
|
||||
}
|
||||
|
||||
function publiserCheckCsrf(): void {
|
||||
global $csrf;
|
||||
$token = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!hash_equals($csrf, $token)) {
|
||||
http_response_code(403);
|
||||
die('Ugyldig forespørsel (utløpt økt). Last siden på nytt og prøv igjen.');
|
||||
}
|
||||
}
|
||||
|
||||
function publiserRedirect(string $url): never {
|
||||
header("Location: $url");
|
||||
exit;
|
||||
}
|
||||
|
||||
function publiserItemDir(string $location, string $section, string $folder): string {
|
||||
return match ($location) {
|
||||
'live' => publiserLiveDir($section, $folder),
|
||||
'draft' => publiserDraftDir($section, $folder),
|
||||
'trash' => publiserTrashDir($section, $folder),
|
||||
default => throw new RuntimeException('Ugyldig plassering'),
|
||||
};
|
||||
}
|
||||
|
||||
// --- minimal Markdown -> HTML for populating the WYSIWYG editor on load ----
|
||||
// (mirrors the bounded tag set publiser.js can serialize back to Markdown)
|
||||
|
||||
function publiserEscapeInline(string $text): string {
|
||||
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function publiserInlineMdToHtml(string $text, callable $imageUrlResolver): string {
|
||||
$text = publiserEscapeInline($text);
|
||||
|
||||
$text = preg_replace_callback('/!\[(.*?)\]\((.*?)\)/', function ($m) use ($imageUrlResolver) {
|
||||
$alt = $m[1];
|
||||
$file = $m[2];
|
||||
$realFile = html_entity_decode($file, ENT_QUOTES, 'UTF-8');
|
||||
$url = htmlspecialchars($imageUrlResolver($realFile), ENT_QUOTES, 'UTF-8');
|
||||
return '<img alt="' . $alt . '" src="' . $url . '" data-filename="' . $file . '">';
|
||||
}, $text) ?? $text;
|
||||
|
||||
$text = preg_replace_callback('/\[(.*?)\]\((.*?)\)/', function ($m) {
|
||||
return '<a href="' . $m[2] . '">' . $m[1] . '</a>';
|
||||
}, $text) ?? $text;
|
||||
|
||||
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text) ?? $text;
|
||||
$text = preg_replace('/(?<!\*)\*([^*]+?)\*(?!\*)/', '<em>$1</em>', $text) ?? $text;
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
function publiserBlockMdToHtml(string $md, callable $imageUrlResolver): string {
|
||||
$md = str_replace("\r\n", "\n", trim($md));
|
||||
if ($md === '') return '<p><br></p>';
|
||||
$blocks = preg_split('/\n{2,}/', $md) ?: [];
|
||||
$html = [];
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
$block = trim($block);
|
||||
if ($block === '') continue;
|
||||
$lines = explode("\n", $block);
|
||||
|
||||
if (preg_match('/^<[a-zA-Z][a-zA-Z0-9-]*(\s[^>]*)?>/', $lines[0])) {
|
||||
// Raw HTML embedded in markdown (e.g. an <iframe> embed) - render
|
||||
// it live but non-editable, and store the exact original source
|
||||
// in data-raw-html so the JS serializer can pass it through
|
||||
// byte-for-byte on save instead of trying to reconstruct it from
|
||||
// the rendered DOM.
|
||||
$html[] = '<div class="p-raw-html" contenteditable="false" data-raw-html="' . htmlspecialchars($block, ENT_QUOTES, 'UTF-8') . '">' . $block . '</div>';
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^### (.*)$/', $lines[0], $m)) {
|
||||
$html[] = '<h3>' . publiserInlineMdToHtml($m[1], $imageUrlResolver) . '</h3>';
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^## (.*)$/', $lines[0], $m)) {
|
||||
$html[] = '<h2>' . publiserInlineMdToHtml($m[1], $imageUrlResolver) . '</h2>';
|
||||
continue;
|
||||
}
|
||||
if (count($lines) === 1 && preg_match('/^!\[(.*?)\]\((.*?)\)$/', $lines[0])) {
|
||||
$html[] = '<p>' . publiserInlineMdToHtml($lines[0], $imageUrlResolver) . '</p>';
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($lines[0], '> ')) {
|
||||
$inner = implode(' ', array_map(fn($l) => preg_replace('/^>\s?/', '', $l), $lines));
|
||||
$html[] = '<blockquote><p>' . publiserInlineMdToHtml($inner, $imageUrlResolver) . '</p></blockquote>';
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^[-*] /', $lines[0])) {
|
||||
$items = array_map(fn($l) => publiserInlineMdToHtml(preg_replace('/^[-*] /', '', $l), $imageUrlResolver), $lines);
|
||||
$html[] = '<ul>' . implode('', array_map(fn($i) => "<li>$i</li>", $items)) . '</ul>';
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^\d+\. /', $lines[0])) {
|
||||
$items = array_map(fn($l) => publiserInlineMdToHtml(preg_replace('/^\d+\. /', '', $l), $imageUrlResolver), $lines);
|
||||
$html[] = '<ol>' . implode('', array_map(fn($i) => "<li>$i</li>", $items)) . '</ol>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$html[] = '<p>' . publiserInlineMdToHtml(implode(' ', $lines), $imageUrlResolver) . '</p>';
|
||||
}
|
||||
|
||||
return implode("\n", $html) ?: '<p><br></p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's visible <h1> comes from a leading "# Title" line in the first
|
||||
* content file (extractTitle() in app/helpers.php relies on the same
|
||||
* convention) - there's no separate metadata-driven heading in page.php.
|
||||
* The editor's "Tittel" field owns that line: stripped here for display/
|
||||
* editing, re-prepended in publiserApplyFormSave() on save.
|
||||
*/
|
||||
function publiserStripLeadingH1(string $md): string {
|
||||
$trimmed = ltrim($md, "\n");
|
||||
if (preg_match('/^#[ \t]+.*(?:\n+|$)/', $trimmed, $m)) {
|
||||
return (string)substr($trimmed, strlen($m[0]));
|
||||
}
|
||||
return $md;
|
||||
}
|
||||
|
||||
// --- form save (metadata + any blocks the user actually touched) ----------
|
||||
|
||||
function publiserApplyFormSave(string $dir, array $post): void {
|
||||
$title = trim((string)($post['title'] ?? ''));
|
||||
$fields = [
|
||||
'title' => $title,
|
||||
'summary' => trim((string)($post['summary'] ?? '')),
|
||||
'tags' => trim((string)($post['tags'] ?? '')),
|
||||
'categories' => trim((string)($post['categories'] ?? '')),
|
||||
];
|
||||
if (!empty($post['date'])) {
|
||||
$fields['date'] = $post['date'];
|
||||
}
|
||||
publiserSaveMetadata($dir, $fields);
|
||||
|
||||
// The first richtext block carries the page's visible <h1> (see
|
||||
// publiserStripLeadingH1()) - it's rewritten every save to stay in sync
|
||||
// with the title field, even if its body wasn't otherwise touched.
|
||||
$titleBlockFilename = null;
|
||||
foreach (publiserListBlocks($dir) as $b) {
|
||||
if ($b['type'] === 'richtext') {
|
||||
$titleBlockFilename = $b['filename'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$contents = $post['block_content'] ?? [];
|
||||
$dirty = $post['block_dirty'] ?? [];
|
||||
if (is_array($contents)) {
|
||||
foreach ($contents as $filename => $content) {
|
||||
$filename = basename((string)$filename);
|
||||
if (!is_file("$dir/$filename")) continue;
|
||||
$content = (string)$content;
|
||||
|
||||
if ($filename === $titleBlockFilename) {
|
||||
if ($title !== '') {
|
||||
publiserSaveBlockContent($dir, $filename, "# {$title}\n\n" . ltrim($content, "\n"));
|
||||
} elseif (!empty($dirty[$filename])) {
|
||||
publiserSaveBlockContent($dir, $filename, $content);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($dirty[$filename])) continue; // untouched blocks are never rewritten
|
||||
publiserSaveBlockContent($dir, $filename, $content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- image upload / serve endpoints ----------------------------------------
|
||||
|
||||
if (($_GET['action'] ?? '') === 'image') {
|
||||
$location = (string)($_GET['location'] ?? 'draft');
|
||||
$section = (string)($_GET['section'] ?? '');
|
||||
$folder = (string)($_GET['folder'] ?? '');
|
||||
$file = basename((string)($_GET['file'] ?? ''));
|
||||
try {
|
||||
$dir = publiserItemDir($location, $section, $folder);
|
||||
$path = "$dir/$file";
|
||||
if (!is_file($path)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mime = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'][$ext] ?? 'application/octet-stream';
|
||||
header("Content-Type: $mime");
|
||||
header('Cache-Control: private, max-age=60');
|
||||
readfile($path);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(404);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if (($_GET['action'] ?? '') === 'upload-image' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
header('Content-Type: application/json');
|
||||
try {
|
||||
publiserCheckCsrf();
|
||||
$location = (string)($_POST['location'] ?? 'draft');
|
||||
$section = (string)($_POST['section'] ?? '');
|
||||
$folder = (string)($_POST['folder'] ?? '');
|
||||
$target = (string)($_POST['target'] ?? 'inline');
|
||||
|
||||
$dir = publiserItemDir($location, $section, $folder);
|
||||
if (!is_dir($dir)) throw new RuntimeException('Fant ikke innlegget');
|
||||
|
||||
if (empty($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('Ingen fil mottatt');
|
||||
}
|
||||
if ($_FILES['image']['size'] > 15 * 1024 * 1024) {
|
||||
throw new RuntimeException('Bildet er for stort (maks 15 MB)');
|
||||
}
|
||||
|
||||
$filename = $target === 'cover'
|
||||
? publiserSaveCoverImage($dir, $_FILES['image']['tmp_name'], $_FILES['image']['name'])
|
||||
: publiserSaveUploadedImage($dir, $_FILES['image']['tmp_name'], $_FILES['image']['name']);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'filename' => $filename,
|
||||
'url' => publiserImageUrl($location, $section, $folder, $filename),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- POST actions (create / save / publish / unpublish / trash / restore) -
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
publiserCheckCsrf();
|
||||
|
||||
try {
|
||||
if (isset($_POST['create'])) {
|
||||
$title = trim((string)($_POST['title'] ?? ''));
|
||||
$newSection = (string)($_POST['new_section'] ?? 'nyheter');
|
||||
$created = publiserCreateDraft($newSection, $title);
|
||||
publiserRedirect(publiserEditUrl('draft', $created['section'], $created['folder']));
|
||||
}
|
||||
|
||||
if (isset($_POST['restore'])) {
|
||||
$result = publiserRestore((string)$_POST['section'], (string)$_POST['folder']);
|
||||
publiserRedirect(publiserEditUrl('draft', $result['section'], $result['folder'], ['restored' => 1]));
|
||||
}
|
||||
|
||||
$postSection = (string)($_POST['section'] ?? '');
|
||||
$postFolder = (string)($_POST['folder'] ?? '');
|
||||
$postLocation = (string)($_POST['location'] ?? 'draft');
|
||||
|
||||
$dir = publiserItemDir($postLocation, $postSection, $postFolder);
|
||||
if (!is_dir($dir)) throw new RuntimeException('Fant ikke innlegget');
|
||||
|
||||
if (isset($_POST['delete_item'])) {
|
||||
publiserTrash($postLocation, $postSection, $postFolder);
|
||||
publiserRedirect(publiserListUrl('drafts', $postSection));
|
||||
}
|
||||
|
||||
// every other action first persists whatever's currently in the form
|
||||
publiserApplyFormSave($dir, $_POST);
|
||||
|
||||
if (isset($_POST['add_block'])) {
|
||||
publiserAddBlock($dir, (string)$_POST['add_block']);
|
||||
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
|
||||
}
|
||||
if (isset($_POST['reorder_up'])) {
|
||||
publiserReorderBlock($dir, (string)$_POST['reorder_up'], 'up');
|
||||
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
|
||||
}
|
||||
if (isset($_POST['reorder_down'])) {
|
||||
publiserReorderBlock($dir, (string)$_POST['reorder_down'], 'down');
|
||||
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
|
||||
}
|
||||
if (isset($_POST['delete_block'])) {
|
||||
publiserDeleteBlock($dir, (string)$_POST['delete_block']);
|
||||
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder));
|
||||
}
|
||||
if (isset($_POST['unschedule'])) {
|
||||
publiserSetSchedule($postSection, $postFolder, null);
|
||||
publiserRedirect(publiserEditUrl('draft', $postSection, $postFolder));
|
||||
}
|
||||
if (isset($_POST['unpublish'])) {
|
||||
$result = publiserUnpublish($postSection, $postFolder);
|
||||
publiserRedirect(publiserEditUrl('draft', $result['section'], $result['folder'], ['unpublished' => 1]));
|
||||
}
|
||||
if (isset($_POST['publish'])) {
|
||||
$scheduleAt = trim((string)($_POST['schedule_at'] ?? ''));
|
||||
if ($scheduleAt !== '' && strtotime($scheduleAt) > time()) {
|
||||
publiserSetSchedule($postSection, $postFolder, $scheduleAt);
|
||||
publiserRedirect(publiserEditUrl('draft', $postSection, $postFolder, ['scheduled' => 1]));
|
||||
}
|
||||
$result = publiserPublish($postSection, $postFolder);
|
||||
publiserRedirect(publiserEditUrl('live', $result['section'], $result['folder'], ['published' => 1]));
|
||||
}
|
||||
|
||||
// plain save
|
||||
publiserRedirect(publiserEditUrl($postLocation, $postSection, $postFolder, ['saved' => 1]));
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(400);
|
||||
publiserShell('Feil', '<div class="p-notice p-notice-error"><p>' . h($e->getMessage()) . '</p><p><a href="' . h(publiserListUrl('published')) . '">Tilbake</a></p></div>');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// --- page shell --------------------------------------------------------
|
||||
|
||||
function publiserShell(string $title, string $body): void {
|
||||
$view = $_GET['view'] ?? 'list';
|
||||
$currentTab = $view === 'list' ? ($_GET['tab'] ?? 'published') : null;
|
||||
$navItems = [
|
||||
'published' => 'Publisert',
|
||||
'scheduled' => 'Planlagt',
|
||||
'drafts' => 'Utkast',
|
||||
'trash' => 'Papirkurv',
|
||||
];
|
||||
$cssHash = file_exists(__DIR__ . '/publiser.css') ? hash_file('md5', __DIR__ . '/publiser.css') : '0';
|
||||
$jsHash = file_exists(__DIR__ . '/publiser.js') ? hash_file('md5', __DIR__ . '/publiser.js') : '0';
|
||||
?><!DOCTYPE html>
|
||||
<html lang="no">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($title) ?> - Publisering</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="stylesheet" href="/app/styles/base.css">
|
||||
<link rel="stylesheet" href="/publiser/publiser.css?v=<?= h($cssHash) ?>">
|
||||
</head>
|
||||
<body class="p-app">
|
||||
<header class="p-topbar">
|
||||
<nav class="p-topbar-inner">
|
||||
<ul>
|
||||
<?php foreach ($navItems as $key => $label): ?>
|
||||
<li><a class="button <?= $currentTab === $key ? 'active' : '' ?>" href="<?= h(publiserListUrl($key)) ?>"><?= h($label) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
<li><a class="button" href="<?= h(publiserBaseUrl()) ?>?view=new">+ Nytt innlegg</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="p-main">
|
||||
<?= $body ?>
|
||||
</main>
|
||||
<script src="/publiser/publiser.js?v=<?= h($jsHash) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
|
||||
// --- views ---------------------------------------------------------------
|
||||
|
||||
function publiserRenderList(string $csrf): void {
|
||||
$tab = (string)($_GET['tab'] ?? 'published');
|
||||
$section = (string)($_GET['section'] ?? 'nyheter');
|
||||
if (!in_array($section, PUBLISER_SECTIONS, true)) $section = 'nyheter';
|
||||
|
||||
$items = match ($tab) {
|
||||
'scheduled' => array_values(array_filter(publiserListStaged('drafts', $section), fn($i) => $i['status'] === 'scheduled')),
|
||||
'drafts' => array_values(array_filter(publiserListStaged('drafts', $section), fn($i) => $i['status'] !== 'scheduled')),
|
||||
'trash' => publiserListStaged('trash', $section),
|
||||
default => publiserListLive($section),
|
||||
};
|
||||
|
||||
ob_start(); ?>
|
||||
<div class="p-header-row">
|
||||
<h1>Innhold</h1>
|
||||
<div class="p-section-switch">
|
||||
<a class="<?= $section === 'nyheter' ? 'active' : '' ?>" href="<?= h(publiserListUrl($tab, 'nyheter')) ?>">Nyheter</a>
|
||||
<a class="<?= $section === 'artikler' ? 'active' : '' ?>" href="<?= h(publiserListUrl($tab, 'artikler')) ?>">Artikler</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_GET['deleted'])): ?><div class="p-notice">Flyttet til papirkurven.</div><?php endif; ?>
|
||||
|
||||
<?php if (empty($items)): ?>
|
||||
<p class="p-empty">Ingen innlegg her ennå.</p>
|
||||
<?php else: ?>
|
||||
<ul class="p-list">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<li class="p-list-item">
|
||||
<?php if ($item['cover']): ?>
|
||||
<img class="p-list-cover" src="<?= h(publiserImageUrl($item['location'], $item['section'], $item['folder'], $item['cover'])) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<div class="p-list-cover p-list-cover-empty"></div>
|
||||
<?php endif; ?>
|
||||
<div class="p-list-info">
|
||||
<div class="p-list-title"><?= h($item['title']) ?></div>
|
||||
<div class="p-list-meta">
|
||||
<?= h($item['date']) ?>
|
||||
<?php if ($tab === 'scheduled' && $item['publishAt']): ?>
|
||||
· publiseres <?= h($item['publishAt']) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-list-actions">
|
||||
<?php if ($tab === 'trash'): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="section" value="<?= h($item['section']) ?>">
|
||||
<input type="hidden" name="folder" value="<?= h($item['folder']) ?>">
|
||||
<button type="submit" name="restore" value="1" class="button">Gjenopprett</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<a class="button" href="<?= h(publiserEditUrl($item['location'], $item['section'], $item['folder'])) ?>">Rediger</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
publiserShell('Innhold', ob_get_clean());
|
||||
}
|
||||
|
||||
function publiserRenderNew(string $csrf): void {
|
||||
ob_start(); ?>
|
||||
<h1>Nytt innlegg</h1>
|
||||
<form method="post" class="p-form p-form-new">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<label>Type
|
||||
<select name="new_section">
|
||||
<option value="nyheter">Nyhet</option>
|
||||
<option value="artikler">Artikkel</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Tittel
|
||||
<input type="text" name="title" required autofocus placeholder="Skriv en tittel...">
|
||||
</label>
|
||||
<button type="submit" name="create" value="1" class="button active">Opprett</button>
|
||||
</form>
|
||||
<?php
|
||||
publiserShell('Nytt innlegg', ob_get_clean());
|
||||
}
|
||||
|
||||
function publiserRenderEditor(string $csrf): void {
|
||||
$location = (string)($_GET['location'] ?? 'draft');
|
||||
$section = (string)($_GET['section'] ?? '');
|
||||
$folder = (string)($_GET['folder'] ?? '');
|
||||
|
||||
$item = publiserLoadItem($location, $section, $folder);
|
||||
if (!$item) {
|
||||
publiserShell('Ikke funnet', '<p>Fant ikke innlegget.</p><p><a href="' . h(publiserListUrl('published')) . '">Tilbake</a></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
$meta = $item['metadata'];
|
||||
if (empty($meta['title'])) {
|
||||
// Legacy items often have no explicit title field - the site
|
||||
// derives it from the first content file's H1 (publiserExtractTitle()).
|
||||
$meta['title'] = publiserExtractTitle($item['dir']) ?? $folder;
|
||||
}
|
||||
$resolver = fn(string $file) => publiserImageUrl($location, $section, $folder, $file);
|
||||
|
||||
if ($location === 'live') {
|
||||
$backTab = 'published';
|
||||
} elseif (($meta['status'] ?? '') === 'scheduled') {
|
||||
$backTab = 'scheduled';
|
||||
} else {
|
||||
$backTab = 'drafts';
|
||||
}
|
||||
|
||||
ob_start(); ?>
|
||||
<div class="p-editor-topline">
|
||||
<a class="p-back" href="<?= h(publiserListUrl($backTab, $section)) ?>">← Tilbake til listen</a>
|
||||
<?php if ($location === 'live'): ?>
|
||||
<a class="p-view-live" href="<?= h(publiserPublicUrl($section, $folder)) ?>" target="_blank" rel="noopener">Se siden ↗</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_GET['saved'])): ?><div class="p-notice">Lagret.</div><?php endif; ?>
|
||||
<?php if (isset($_GET['published'])): ?><div class="p-notice">Publisert.</div><?php endif; ?>
|
||||
<?php if (isset($_GET['unpublished'])): ?><div class="p-notice">Avpublisert - ligger nå i utkast.</div><?php endif; ?>
|
||||
<?php if (isset($_GET['scheduled'])): ?><div class="p-notice">Planlagt publisering satt.</div><?php endif; ?>
|
||||
<?php if (isset($_GET['restored'])): ?><div class="p-notice">Gjenopprettet til utkast.</div><?php endif; ?>
|
||||
|
||||
<?php if ($location === 'trash'): ?>
|
||||
<h1><?= h($meta['title'] ?? $folder) ?></h1>
|
||||
<p class="p-muted">Ligger i papirkurven.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="section" value="<?= h($section) ?>">
|
||||
<input type="hidden" name="folder" value="<?= h($folder) ?>">
|
||||
<button type="submit" name="restore" value="1" class="button active">Gjenopprett til utkast</button>
|
||||
</form>
|
||||
<?php publiserShell(h($meta['title'] ?? $folder), ob_get_clean()); return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" id="editor-form" class="p-editor p-form" data-location="<?= h($location) ?>" data-section="<?= h($section) ?>" data-folder="<?= h($folder) ?>" data-upload-url="<?= h(publiserBaseUrl() . '?action=upload-image') ?>" data-csrf="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="csrf" value="<?= h($csrf) ?>">
|
||||
<input type="hidden" name="location" value="<?= h($location) ?>">
|
||||
<input type="hidden" name="section" value="<?= h($section) ?>">
|
||||
<input type="hidden" name="folder" value="<?= h($folder) ?>">
|
||||
|
||||
<div class="p-editor-grid">
|
||||
<div class="p-editor-main">
|
||||
<input type="text" name="title" class="p-title-input" value="<?= h($meta['title'] ?? '') ?>" placeholder="Tittel" required>
|
||||
|
||||
<div class="p-blocks">
|
||||
<?php $titleBlockSeen = false; ?>
|
||||
<?php foreach ($item['blocks'] as $block): ?>
|
||||
<?php
|
||||
$raw = file_get_contents($item['dir'] . '/' . $block['filename']) ?: '';
|
||||
$isTitleBlock = !$titleBlockSeen && $block['type'] === 'richtext';
|
||||
if ($isTitleBlock) {
|
||||
$titleBlockSeen = true;
|
||||
$raw = publiserStripLeadingH1($raw);
|
||||
}
|
||||
?>
|
||||
<div class="p-block" data-filename="<?= h($block['filename']) ?>" data-type="<?= h($block['type']) ?>">
|
||||
<div class="p-block-toolbar">
|
||||
<?php if ($block['type'] === 'richtext'): ?>
|
||||
<button type="button" data-cmd="bold" title="Fet"><strong>B</strong></button>
|
||||
<button type="button" data-cmd="italic" title="Kursiv"><em>I</em></button>
|
||||
<button type="button" data-cmd="h2" title="Overskrift">H2</button>
|
||||
<button type="button" data-cmd="h3" title="Underoverskrift">H3</button>
|
||||
<button type="button" data-cmd="ul" title="Punktliste">• Liste</button>
|
||||
<button type="button" data-cmd="ol" title="Nummerert liste">1. Liste</button>
|
||||
<button type="button" data-cmd="quote" title="Sitat">” Sitat</button>
|
||||
<button type="button" data-cmd="link" title="Lenke">🔗 Lenke</button>
|
||||
<button type="button" data-cmd="image" title="Sett inn bilde">📷 Bilde</button>
|
||||
<?php else: ?>
|
||||
<span class="p-block-label">Egendefinert kode (<?= h(strtoupper($block['ext'])) ?>) - vises ikke visuelt her</span>
|
||||
<?php endif; ?>
|
||||
<span class="p-block-spacer"></span>
|
||||
<button type="submit" name="reorder_up" value="<?= h($block['filename']) ?>" title="Flytt opp">↑</button>
|
||||
<button type="submit" name="reorder_down" value="<?= h($block['filename']) ?>" title="Flytt ned">↓</button>
|
||||
<button type="submit" name="delete_block" value="<?= h($block['filename']) ?>" class="danger" data-confirm="Slette denne blokken?" title="Slett blokk">×</button>
|
||||
</div>
|
||||
<?php if ($block['type'] === 'richtext'): ?>
|
||||
<div class="p-richtext" contenteditable="true"><?= publiserBlockMdToHtml($raw, $resolver) ?></div>
|
||||
<textarea class="p-block-hidden" name="block_content[<?= h($block['filename']) ?>]" hidden><?= h($raw) ?></textarea>
|
||||
<input type="hidden" name="block_dirty[<?= h($block['filename']) ?>]" value="0" class="p-dirty-flag">
|
||||
<?php else: ?>
|
||||
<textarea class="p-code" rows="8" name="block_content[<?= h($block['filename']) ?>]"><?= h($raw) ?></textarea>
|
||||
<input type="hidden" name="block_dirty[<?= h($block['filename']) ?>]" value="1" class="p-dirty-flag">
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="p-add-block">
|
||||
<button type="submit" name="add_block" value="md" class="button">+ Tekst</button>
|
||||
<button type="submit" name="add_block" value="html" class="button">+ Egendefinert HTML (avansert)</button>
|
||||
<button type="submit" name="add_block" value="php" class="button">+ Egendefinert PHP (avansert)</button>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($item['extra'])): ?>
|
||||
<div class="p-extra-files">
|
||||
<p class="p-muted">Andre filer i denne mappen (rediger disse direkte i kildekoden):</p>
|
||||
<ul>
|
||||
<?php foreach ($item['extra'] as $extra): ?>
|
||||
<?php
|
||||
$icon = match ($extra['type']) {
|
||||
'folder' => '📁',
|
||||
'translation' => '🌐',
|
||||
default => '📄',
|
||||
};
|
||||
$note = $extra['type'] === 'translation' ? ' (engelsk oversettelse)' : '';
|
||||
?>
|
||||
<li><?= h($icon) ?> <?= h($extra['name']) ?><?= h($note) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="p-editor-sidebar">
|
||||
<div class="p-side-box">
|
||||
<p class="p-side-label">Metadata</p>
|
||||
<label>Sammendrag
|
||||
<textarea name="summary" rows="3" placeholder="Vises i lister og deling"><?= h($meta['summary'] ?? '') ?></textarea>
|
||||
</label>
|
||||
<label>Stikkord (kommaseparert)
|
||||
<input type="text" name="tags" value="<?= h($meta['tags'] ?? '') ?>">
|
||||
</label>
|
||||
<label>Kategori
|
||||
<input type="text" name="categories" value="<?= h($meta['categories'] ?? '') ?>">
|
||||
</label>
|
||||
<?php if ($section === 'nyheter'): ?>
|
||||
<label>Dato
|
||||
<input type="date" name="date" value="<?= h($meta['date'] ?? date('Y-m-d')) ?>">
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="p-side-box">
|
||||
<p class="p-side-label">Forsidebilde</p>
|
||||
<div class="p-cover-drop" data-target="cover">
|
||||
<?php if ($item['cover']): ?>
|
||||
<img src="<?= h(publiserImageUrl($location, $section, $folder, $item['cover'])) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<span>Slipp bilde her eller klikk for å velge</span>
|
||||
<?php endif; ?>
|
||||
<input type="file" accept="image/*" hidden>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-side-box p-actions-box">
|
||||
<button type="submit" name="save" value="1" class="button active">Lagre</button>
|
||||
|
||||
<?php if ($location === 'draft'): ?>
|
||||
<?php if (($meta['status'] ?? '') === 'scheduled'): ?>
|
||||
<p class="p-muted">Planlagt: <?= h($meta['publish_at'] ?? '') ?></p>
|
||||
<button type="submit" name="unschedule" value="1" class="button">Avbryt planlegging</button>
|
||||
<?php endif; ?>
|
||||
<label>Planlegg publisering (valgfritt)
|
||||
<input type="datetime-local" name="schedule_at" value="<?= h($meta['publish_at'] ?? '') ?>">
|
||||
</label>
|
||||
<button type="submit" name="publish" value="1" class="button active">Publiser</button>
|
||||
<?php else: ?>
|
||||
<button type="submit" name="unpublish" value="1" class="button">Avpubliser</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="submit" name="delete_item" value="1" class="button danger" data-confirm="Flytte dette innlegget til papirkurven?">Slett</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<?php
|
||||
publiserShell(h($meta['title'] ?? $folder), ob_get_clean());
|
||||
}
|
||||
|
||||
// --- dispatch --------------------------------------------------------------
|
||||
|
||||
$view = (string)($_GET['view'] ?? 'list');
|
||||
match ($view) {
|
||||
'new' => publiserRenderNew($csrf),
|
||||
'edit' => publiserRenderEditor($csrf),
|
||||
default => publiserRenderList($csrf),
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue