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:
Ruben 2026-08-30 21:54:17 +02:00
parent c7ec163a36
commit b609c8596c
15 changed files with 2301 additions and 8 deletions

726
content/publiser/index.php Normal file
View 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) . '&section=' . urlencode($section);
}
function publiserEditUrl(string $location, string $section, string $folder, array $flags = []): string {
$url = publiserBaseUrl() . '?view=edit&location=' . urlencode($location)
. '&section=' . 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)
. '&section=' . 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']): ?>
&middot; 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)) ?>">&larr; Tilbake til listen</a>
<?php if ($location === 'live'): ?>
<a class="p-view-live" href="<?= h(publiserPublicUrl($section, $folder)) ?>" target="_blank" rel="noopener">Se siden &#8599;</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">&bull; Liste</button>
<button type="button" data-cmd="ol" title="Nummerert liste">1. Liste</button>
<button type="button" data-cmd="quote" title="Sitat">&rdquo; Sitat</button>
<button type="button" data-cmd="link" title="Lenke">&#128279; Lenke</button>
<button type="button" data-cmd="image" title="Sett inn bilde">&#128247; 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">&uarr;</button>
<button type="submit" name="reorder_down" value="<?= h($block['filename']) ?>" title="Flytt ned">&darr;</button>
<button type="submit" name="delete_block" value="<?= h($block['filename']) ?>" class="danger" data-confirm="Slette denne blokken?" title="Slett blokk">&times;</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),
};