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