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

View file

@ -0,0 +1,15 @@
DirectorySlash On
# Auth is checked in PHP (index.php), not via Apache AuthUserFile: relative
# AuthUserFile paths resolve against ServerRoot, not this directory, and
# that differs per environment (podman dev container vs cPanel). Some SAPIs
# (LSAPI/CGI/FastCGI, used on cPanel) strip the Authorization header from
# PHP by default, so forward it explicitly via an internal env var.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
</IfModule>
<Files ".htpasswd">
Require all denied
</Files>

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),
};

View file

@ -0,0 +1,376 @@
/* /publiser admin app - reuses the site's CSS variables (base.css) for
brand consistency, but is otherwise a self-contained layout. Follows the
site convention of margin-top-only spacing (global reset zeroes
margin-bottom). */
/* Reuses the site's own .button class for nav/action links (base.css), so
they inherit real site styling instead of parallel custom classes. The
header bar itself has no site equivalent (the public site's header is a
narrower, logo-anchored layout) - .p-topbar-inner matches .p-main's width
instead, left-aligned. */
.p-app {
font-family: var(--font-body);
color: var(--color-grey);
background: var(--color-green-light);
min-height: 100vh;
}
.p-topbar {
background: #fff;
border-bottom: 3px #00000022 solid;
}
.p-topbar-inner {
max-width: 64rem;
margin: 0 auto;
padding: .8rem 1rem;
box-sizing: border-box;
}
.p-topbar-inner ul {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: .6rem;
margin: 0;
padding: 0;
}
.p-main {
max-width: 64rem;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
}
h1 {
font-family: var(--font-heading);
color: var(--color-green);
font-size: 1.8rem;
font-weight: 400;
}
.p-muted { color: #777; font-size: .9rem }
.p-editor-topline {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.p-back, .p-view-live {
display: inline-block;
margin-top: 0;
font-size: .9rem;
}
.p-notice {
margin-top: .8rem;
padding: .6rem .9rem;
background: var(--color-green-light);
border: 1px solid var(--color-green);
border-radius: .4rem;
color: var(--color-grey);
}
.p-notice-error {
background: #fdecea;
border-color: #c0392b;
}
/* --- list view --- */
.p-header-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 1rem;
justify-content: space-between;
}
.p-section-switch a {
padding: .3rem .7rem;
border-radius: .4rem;
font-size: .9rem;
}
.p-section-switch a.active {
background: var(--color-green);
color: #fff !important;
}
.p-empty { margin-top: 2rem; color: #777 }
.p-list {
list-style: none;
margin-top: 1rem;
padding: 0;
display: flex;
flex-direction: column;
gap: .6rem;
}
.p-list-item {
display: flex;
align-items: center;
gap: 1rem;
background: #fff;
border-radius: .5rem;
padding: .6rem .9rem;
border: 1px solid #00000012;
}
.p-list-cover {
width: 4rem;
height: 4rem;
object-fit: cover;
border-radius: .4rem;
flex: none;
}
.p-list-cover-empty { background: #eee }
.p-list-info { flex: 1; min-width: 0 }
.p-list-title {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.p-list-meta { font-size: .85rem; color: #777; margin-top: .1rem }
.p-list-actions form { margin: 0 }
/* --- buttons & forms --- */
/* base.css's .button has no destructive/danger variant - the one addition
needed on top of the site's real button styling. */
.button.danger { outline-color: #c0392b; color: #c0392b }
.button.danger:hover, .button.danger:focus { background-color: #c0392b; color: #fff; outline: none }
.p-form label {
display: block;
margin-top: 1rem;
font-size: .82rem;
font-weight: 600;
color: #555;
}
.p-side-box .p-form label:first-of-type,
.p-side-box label:first-of-type { margin-top: .6rem }
.p-form input[type=text],
.p-form input[type=date],
.p-form input[type=datetime-local],
.p-form textarea,
.p-form select {
display: block;
width: 100%;
margin-top: .35rem;
padding: .5rem .6rem;
border: 1px solid #00000022;
border-radius: .4rem;
font-family: var(--font-body);
font-size: .95rem;
color: var(--color-grey);
background: #fbfbfa;
box-sizing: border-box;
transition: border-color .15s, background .15s;
}
.p-form input[type=text]:focus,
.p-form input[type=date]:focus,
.p-form input[type=datetime-local]:focus,
.p-form textarea:focus,
.p-form select:focus {
outline: none;
border-color: var(--color-green);
background: #fff;
}
.p-form textarea { resize: vertical }
/* .p-form textarea's `display: block` above outranks the browser's native
[hidden] { display: none }, which un-hides this fallback field (it holds
the untouched block's raw content and must never actually show). Force it
back off with higher specificity. */
.p-form textarea.p-block-hidden { display: none }
.p-form-new { max-width: 28rem }
.p-form-new button { margin-top: 1.5rem }
/* --- editor --- */
.p-editor-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 18rem;
gap: 1.5rem;
margin-top: 1rem;
align-items: start;
}
@media (max-width: 55rem) {
.p-editor-grid { grid-template-columns: 1fr }
}
.p-title-input {
width: 100%;
font-family: var(--font-heading);
font-size: 1.8rem;
border: none;
border-bottom: 2px solid #00000015;
padding: .3rem 0;
background: transparent;
box-sizing: border-box;
}
.p-title-input:focus { outline: none; border-bottom-color: var(--color-green) }
.p-blocks {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
.p-block {
background: #fff;
border: 1px solid #00000015;
border-radius: .5rem;
overflow: hidden;
}
.p-block-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .2rem;
padding: .4rem;
background: #fafaf8;
border-bottom: 1px solid #00000012;
}
.p-block-toolbar button {
border: 1px solid transparent;
background: transparent;
padding: .3rem .55rem;
border-radius: .3rem;
cursor: pointer;
font-size: .9rem;
color: var(--color-grey);
font-family: var(--font-body);
}
.p-block-toolbar button:hover { background: #eee }
.p-block-toolbar button.danger { color: #c0392b }
.p-block-toolbar button.danger:hover { background: #fdecea }
.p-block-spacer { flex: 1 }
.p-block-label { font-size: .85rem; color: #777; padding: 0 .4rem }
.p-richtext {
min-height: 6rem;
padding: 1rem;
line-height: 1.5;
}
.p-richtext:focus { outline: none; background: #fffef8 }
.p-richtext h2, .p-richtext h3 { margin-top: .6em; color: var(--color-green) }
.p-richtext p { margin-top: .8em }
.p-richtext p:first-child { margin-top: 0 }
.p-richtext img { max-width: 100%; border-radius: .3rem; margin-top: .5em }
.p-richtext blockquote {
margin-top: .8em;
padding-left: 1rem;
border-left: 3px solid var(--color-green);
color: #555;
}
.p-richtext ul, .p-richtext ol { margin-top: .5em; padding-left: 1.4rem }
.p-raw-html {
margin-top: .8em;
padding: .6rem;
border: 1px dashed #00000030;
border-radius: .4rem;
background: #fafaf8;
cursor: default;
position: relative;
}
.p-raw-html::before {
content: "Innebygd kode - kan flyttes/slettes, men ikke redigeres her";
display: block;
margin-bottom: .5em;
font-size: .75rem;
color: #888;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.p-code {
width: 100%;
box-sizing: border-box;
border: none;
padding: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .85rem;
resize: vertical;
}
.p-code:focus { outline: none; background: #fffef8 }
.p-add-block {
display: flex;
flex-wrap: wrap;
gap: .6rem;
margin-top: 1rem;
}
.p-extra-files {
margin-top: 1.5rem;
padding: .8rem;
background: #fafaf8;
border-radius: .4rem;
font-size: .9rem;
}
.p-extra-files ul { margin-top: .3rem; padding-left: 1.2rem }
.p-editor-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.p-side-box {
background: #fff;
border: 1px solid #00000015;
border-radius: .6rem;
padding: 1.1rem;
box-shadow: 0 1px 2px #00000008;
}
.p-side-label {
font-family: var(--font-heading);
font-size: 1rem;
font-weight: 400;
color: var(--color-green);
padding-bottom: .5rem;
border-bottom: 1px solid #00000012;
}
.p-actions-box { display: flex; flex-direction: column; gap: .6rem }
.p-actions-box .button { width: 100%; text-align: center; box-sizing: border-box }
.p-actions-box label { margin-top: .8rem }
.p-actions-box .p-muted { margin-top: 0 }
.p-cover-drop {
margin-top: .5rem;
border: 2px dashed #00000030;
border-radius: .4rem;
min-height: 6rem;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: .85rem;
color: #777;
cursor: pointer;
padding: .5rem;
overflow: hidden;
}
.p-cover-drop.dragover { border-color: var(--color-green); background: var(--color-green-light) }
.p-cover-drop img { width: 100%; height: 8rem; object-fit: cover; border-radius: .3rem }
@media (max-width: 30rem) {
.p-main { padding: 1rem .7rem 4rem }
.p-list-item { flex-wrap: wrap }
}

View file

@ -0,0 +1,382 @@
/* /publiser block editor: rich-text toolbar for .md blocks, plain code
* editing for .html/.php blocks, image upload (cover + inline), and a
* small Markdown serializer for the bounded tag set the toolbar produces.
* No external dependencies - contenteditable + document.execCommand for
* the handful of inline/list commands (still functional everywhere,
* despite being spec-deprecated), manual DOM ops for block-level toggles.
*/
(function () {
'use strict';
document.execCommand('defaultParagraphSeparator', false, 'p');
// --- Markdown serialization (HTML -> MD, mirrors index.php's MD -> HTML) --
function serializeChildren(node) {
return Array.from(node.childNodes).map(serializeNode).join('');
}
function serializeNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
// Not escaping markdown-significant characters (*, [, ] ...) in plain
// text: round-tripping literal asterisks/brackets through the parser
// is a rare, low-severity edge case not worth the added complexity.
return node.textContent;
}
if (node.nodeType !== Node.ELEMENT_NODE) return '';
if (node.hasAttribute('data-raw-html')) {
// Non-editable raw-HTML island (see index.php's publiserBlockMdToHtml)
// - pass the original source through unchanged rather than trying to
// reconstruct it from the rendered DOM.
return `\n\n${node.getAttribute('data-raw-html')}\n\n`;
}
const tag = node.tagName.toLowerCase();
switch (tag) {
case 'strong':
case 'b': {
const inner = serializeChildren(node).trim();
return inner ? `**${inner}**` : '';
}
case 'em':
case 'i': {
const inner = serializeChildren(node).trim();
return inner ? `*${inner}*` : '';
}
case 'a': {
const href = node.getAttribute('href') || '';
const inner = serializeChildren(node).trim();
return href ? `[${inner}](${href})` : inner;
}
case 'img': {
const file = node.getAttribute('data-filename') || node.getAttribute('src') || '';
const alt = node.getAttribute('alt') || '';
return `![${alt}](${file})`;
}
case 'br':
return ' ';
case 'h2':
return `\n\n## ${serializeChildren(node).trim()}\n\n`;
case 'h3':
return `\n\n### ${serializeChildren(node).trim()}\n\n`;
case 'blockquote': {
const text = serializeChildren(node).trim().replace(/\s+/g, ' ');
return text ? `\n\n> ${text}\n\n` : '';
}
case 'ul': {
const items = Array.from(node.children)
.filter((c) => c.tagName === 'LI')
.map((li) => `- ${serializeChildren(li).trim()}`)
.join('\n');
return items ? `\n\n${items}\n\n` : '';
}
case 'ol': {
const items = Array.from(node.children)
.filter((c) => c.tagName === 'LI')
.map((li, i) => `${i + 1}. ${serializeChildren(li).trim()}`)
.join('\n');
return items ? `\n\n${items}\n\n` : '';
}
case 'li':
return serializeChildren(node);
case 'p':
case 'div': {
const inner = serializeChildren(node).trim();
return inner ? `\n\n${inner}\n\n` : '';
}
default:
return serializeChildren(node);
}
}
function serializeRichText(root) {
let out = Array.from(root.childNodes).map(serializeNode).join('');
out = out.replace(/[ \t]+\n/g, '\n');
out = out.replace(/\n{3,}/g, '\n\n');
return out.trim();
}
// --- dirty tracking ---------------------------------------------------
function markDirty(richDiv) {
const block = richDiv.closest('.p-block');
if (!block) return;
const dirtyFlag = block.querySelector('.p-dirty-flag');
const hidden = block.querySelector('.p-block-hidden');
if (dirtyFlag) dirtyFlag.value = '1';
if (hidden) hidden.value = serializeRichText(richDiv);
}
// --- selection helpers --------------------------------------------------
function saveSelection(richDiv) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if (!richDiv.contains(range.startContainer)) return null;
return range.cloneRange();
}
function restoreSelection(range) {
if (!range) return;
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
function placeCaretAtEnd(el) {
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
function getCurrentBlock(richDiv) {
const sel = window.getSelection();
if (!sel.rangeCount) return null;
let node = sel.getRangeAt(0).startContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
while (node && node !== richDiv && !['P', 'DIV', 'H2', 'H3', 'BLOCKQUOTE', 'LI'].includes(node.tagName)) {
node = node.parentElement;
}
return node === richDiv ? null : node;
}
function toggleBlockTag(richDiv, tagName) {
const block = getCurrentBlock(richDiv);
if (!block) return;
const targetTag = block.tagName === tagName ? 'P' : tagName;
const replacement = document.createElement(targetTag);
replacement.innerHTML = block.innerHTML || '<br>';
block.replaceWith(replacement);
placeCaretAtEnd(replacement);
}
// --- image upload -------------------------------------------------------
async function uploadImage(file, form, target) {
const fd = new FormData();
fd.append('image', file);
fd.append('csrf', form.dataset.csrf);
fd.append('location', form.dataset.location);
fd.append('section', form.dataset.section);
fd.append('folder', form.dataset.folder);
fd.append('target', target);
try {
const res = await fetch(form.dataset.uploadUrl, { method: 'POST', body: fd });
const data = await res.json();
if (!data.ok) {
alert('Opplasting feilet: ' + (data.error || 'ukjent feil'));
return null;
}
return data;
} catch (err) {
alert('Opplasting feilet: ' + err.message);
return null;
}
}
function insertImageAtRange(richDiv, range, imgData) {
const el = document.createElement('img');
el.src = imgData.url;
el.alt = '';
el.setAttribute('data-filename', imgData.filename);
restoreSelection(range);
const sel = window.getSelection();
if (sel.rangeCount && richDiv.contains(sel.getRangeAt(0).startContainer)) {
const r = sel.getRangeAt(0);
r.deleteContents();
r.insertNode(el);
r.setStartAfter(el);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
} else {
richDiv.appendChild(el);
}
markDirty(richDiv);
}
async function uploadAndInsertImage(richDiv, file, range) {
const form = richDiv.closest('form');
const data = await uploadImage(file, form, 'inline');
if (!data) return;
insertImageAtRange(richDiv, range, data);
}
function triggerImageInsert(richDiv) {
const savedRange = saveSelection(richDiv);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
if (input.files[0]) uploadAndInsertImage(richDiv, input.files[0], savedRange);
});
input.click();
}
// --- toolbar commands -----------------------------------------------
function execToolbarCommand(cmd, richDiv) {
richDiv.focus();
switch (cmd) {
case 'bold':
document.execCommand('bold');
break;
case 'italic':
document.execCommand('italic');
break;
case 'ul':
document.execCommand('insertUnorderedList');
break;
case 'ol':
document.execCommand('insertOrderedList');
break;
case 'link': {
const url = prompt('Lenke (URL):', 'https://');
if (url) document.execCommand('createLink', false, url);
break;
}
case 'h2':
toggleBlockTag(richDiv, 'H2');
break;
case 'h3':
toggleBlockTag(richDiv, 'H3');
break;
case 'quote':
toggleBlockTag(richDiv, 'BLOCKQUOTE');
break;
case 'image':
triggerImageInsert(richDiv);
return; // markDirty happens after upload completes
default:
return;
}
markDirty(richDiv);
}
// --- wire up rich text blocks -------------------------------------------
document.querySelectorAll('.p-block').forEach((block) => {
const richDiv = block.querySelector('.p-richtext');
if (!richDiv) return;
richDiv.addEventListener('input', () => markDirty(richDiv));
richDiv.addEventListener('paste', (e) => {
const cd = e.clipboardData || window.clipboardData;
const imageFile = Array.from(cd.files || []).find((f) => f.type.startsWith('image/'));
e.preventDefault();
if (imageFile) {
const sel = window.getSelection();
const range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
uploadAndInsertImage(richDiv, imageFile, range);
return;
}
const text = cd.getData('text/plain');
document.execCommand('insertText', false, text);
markDirty(richDiv);
});
richDiv.addEventListener('dragover', (e) => {
e.preventDefault();
richDiv.classList.add('dragover');
});
richDiv.addEventListener('dragleave', () => richDiv.classList.remove('dragover'));
richDiv.addEventListener('drop', (e) => {
e.preventDefault();
richDiv.classList.remove('dragover');
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) return;
let range = null;
if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(e.clientX, e.clientY);
} else if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
if (pos) {
range = document.createRange();
range.setStart(pos.offsetNode, pos.offset);
}
}
uploadAndInsertImage(richDiv, file, range);
});
block.querySelectorAll('.p-block-toolbar [data-cmd]').forEach((btn) => {
btn.addEventListener('click', () => execToolbarCommand(btn.dataset.cmd, richDiv));
});
});
// --- cover image dropzone ------------------------------------------------
function wireCoverDrop(drop) {
const form = drop.closest('form');
let input = drop.querySelector('input[type=file]');
async function handleFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const previousContent = drop.innerHTML;
drop.textContent = 'Laster opp...';
const data = await uploadImage(file, form, 'cover');
if (!data) {
drop.innerHTML = previousContent;
return;
}
drop.innerHTML = '';
const img = document.createElement('img');
img.src = data.url;
drop.appendChild(img);
const newInput = document.createElement('input');
newInput.type = 'file';
newInput.accept = 'image/*';
newInput.hidden = true;
drop.appendChild(newInput);
input = newInput;
newInput.addEventListener('change', () => handleFile(newInput.files[0]));
}
drop.addEventListener('click', () => input && input.click());
if (input) input.addEventListener('change', () => handleFile(input.files[0]));
drop.addEventListener('dragover', (e) => {
e.preventDefault();
drop.classList.add('dragover');
});
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
drop.addEventListener('drop', (e) => {
e.preventDefault();
drop.classList.remove('dragover');
handleFile(e.dataTransfer.files && e.dataTransfer.files[0]);
});
}
document.querySelectorAll('.p-cover-drop').forEach(wireCoverDrop);
// --- confirmations for destructive submit buttons -----------------------
document.querySelectorAll('[data-confirm]').forEach((btn) => {
btn.addEventListener('click', (e) => {
if (!confirm(btn.dataset.confirm)) e.preventDefault();
});
});
// --- safety net: resync any dirty block right before submit -------------
const form = document.getElementById('editor-form');
if (form) {
form.addEventListener('submit', () => {
form.querySelectorAll('.p-block[data-type="richtext"]').forEach((block) => {
const richDiv = block.querySelector('.p-richtext');
const dirtyFlag = block.querySelector('.p-dirty-flag');
if (richDiv && dirtyFlag && dirtyFlag.value === '1') {
block.querySelector('.p-block-hidden').value = serializeRichText(richDiv);
}
});
});
}
})();