Add helper functions to display human-friendly Norwegian dates and times in the publishing dashboard and editor. Add documentation for the publiser tool and update security checklists.
756 lines
35 KiB
PHP
756 lines
35 KiB
PHP
<?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) . '/';
|
|
}
|
|
|
|
/**
|
|
* Human-friendly display of a stored publish_at value (e.g. "2026-08-30T14:30",
|
|
* the raw <input type=datetime-local> format). Mirrors the day/month/year
|
|
* order app/plugins/global/languages.php's formatDate() uses for regular
|
|
* post dates, extended with a time-of-day. Only for display - never use
|
|
* this for the datetime-local input's own value attribute, which must stay
|
|
* in machine format.
|
|
*/
|
|
function publiserNorwegianDate(int $ts): string {
|
|
static $months = ['januar', 'februar', 'mars', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'desember'];
|
|
$day = (int)date('j', $ts);
|
|
$month = $months[(int)date('n', $ts) - 1];
|
|
return "{$day}. {$month} " . date('Y', $ts);
|
|
}
|
|
|
|
/** Human-friendly display of a date-only value (e.g. "2026-08-25"). Never
|
|
* use this for the <input type=date> value attribute, which must stay ISO. */
|
|
function publiserFormatDate(?string $raw): string {
|
|
if (!$raw) return '';
|
|
$ts = strtotime($raw);
|
|
return $ts === false ? $raw : publiserNorwegianDate($ts);
|
|
}
|
|
|
|
function publiserFormatScheduleTime(?string $raw): string {
|
|
if (!$raw) return '';
|
|
$ts = strtotime($raw);
|
|
if ($ts === false) return $raw;
|
|
return publiserNorwegianDate($ts) . ' kl. ' . date('H:i', $ts);
|
|
}
|
|
|
|
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(publiserFormatDate($item['date'])) ?>
|
|
<?php if ($tab === 'scheduled' && $item['publishAt']): ?>
|
|
· publiseres <?= h(publiserFormatScheduleTime($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(publiserFormatScheduleTime($meta['publish_at'] ?? null)) ?></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),
|
|
};
|