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

@ -2,8 +2,8 @@ DirectorySlash Off
# Block direct access to content source files
<FilesMatch "\.(ini|md|html|php)$">
# Allow only the entry point
<If "%{REQUEST_URI} != '/index.php'">
# Allow only the entry point, and the standalone /publiser admin app
<If "%{REQUEST_URI} != '/index.php' && %{REQUEST_URI} !~ m#^/publiser(/|$)#">
Require all denied
</If>
</FilesMatch>
@ -30,6 +30,13 @@ DirectorySlash Off
RewriteCond %{REQUEST_URI} ^/app/
RewriteRule ^(.*)$ /index.php [L,QSA]
# Standalone /publiser admin app - bypass the CMS entirely, dispatch
# everything to its own entry point (static assets like .css/.js are
# excluded so Apache serves those files directly)
RewriteCond %{REQUEST_URI} ^/publiser
RewriteCond %{REQUEST_URI} !\.(css|js|png|jpe?g|webp|gif|svg)$
RewriteRule ^publiser(/.*)?$ publiser/index.php [L,QSA]
# Don't rewrite if file exists
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ /index.php [L,QSA]

View file

@ -2,8 +2,8 @@ DirectorySlash Off
# Block direct access to content source files
<FilesMatch "\.(ini|md|html|php)$">
# Allow only the entry point
<If "%{REQUEST_URI} != '/index.php'">
# Allow only the entry point, and the standalone /publiser admin app
<If "%{REQUEST_URI} != '/index.php' && %{REQUEST_URI} !~ m#^/publiser(/|$)#">
Require all denied
</If>
</FilesMatch>
@ -30,6 +30,13 @@ DirectorySlash Off
RewriteCond %{REQUEST_URI} ^/app/
RewriteRule ^(.*)$ /index.php [L,QSA]
# Standalone /publiser admin app - bypass the CMS entirely, dispatch
# everything to its own entry point (static assets like .css/.js are
# excluded so Apache serves those files directly)
RewriteCond %{REQUEST_URI} ^/publiser
RewriteCond %{REQUEST_URI} !\.(css|js|png|jpe?g|webp|gif|svg)$
RewriteRule ^publiser(/.*)?$ publiser/index.php [L,QSA]
# Don't rewrite if file exists
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ /index.php [L,QSA]

View file

@ -11,5 +11,3 @@ Uten førerkort blir hverdagen veldig vanskelig: matbutikk, legeavtaler og famil
Denne rettssaken er en unik mulighet til å utfordre regelverket og skape reell endring. Ditt bidrag går direkte til rettssaken en kamp for rettferdighet, medisinsk frihet og et verdig liv for alle pasienter. Bidra nå, og vær med på å endre regelverket!
Gi ditt bidrag i dag hver krone teller! Les Glenn Dahl [sin historie på NRK.no](https://www.nrk.no/norge/nektes-a-kjore-bil-fordi-han-bruker-medisinsk-cannabis-1.17210314).
<iframe style="padding-top:1rem" width='350' height='535' src='https://www.spleis.no/project/483880/embed' frameborder='0'></iframe>

View file

@ -0,0 +1 @@
<iframe style="padding-top:1rem" width='350' height='535' src='https://www.spleis.no/project/483880/embed' frameborder='0'></iframe>

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