32 Commits

Author SHA1 Message Date
230476e8e5 Claude redid the router 2026-06-30 01:35:55 -07:00
d5871ed8e7 minor updates 2026-06-16 19:00:30 -07:00
ad6e07c76d fixed css and changed timezone 2026-05-04 11:30:14 -07:00
06310d9eb9 Updated readme and composer 2026-05-03 13:27:30 -07:00
f679d0b20e safety fix 2026-02-07 17:26:26 -08:00
9feccf9eaa tabs and draft working 2026-01-26 21:55:22 -08:00
14ec6b7e7a fixed matomo to be in the master twig template. 2026-01-22 15:40:46 -08:00
42a828a778 edit page template 2026-01-12 19:00:03 -08:00
81c239f043 added matomo 2026-01-09 12:41:55 -08:00
0d91b61bd0 control panel head changes 2025-12-22 18:32:35 -08:00
fd9a71c4d6 added page to the front end 2025-12-16 05:35:56 -08:00
71413283c8 added humans and robots 2025-12-15 09:54:57 -08:00
a32956b4a2 added coming soon page 2025-12-15 09:28:09 -08:00
7bf3dc6610 added coming soon page 2025-12-15 09:27:56 -08:00
7b064eb6da morning changes 2025-12-15 06:14:07 -08:00
934e134941 ready to test new layout 2025-12-14 03:45:07 -08:00
6f410b5d0e added logs to docker 2025-12-07 23:10:32 -08:00
e5aadb3b82 final checks and fixes for launch 2025-12-07 20:33:42 -08:00
08b8009dec tags working 2025-12-06 01:09:39 -08:00
208534b5fb fixed twig and functions 2025-12-05 18:04:17 -08:00
466d34c39f made the code more composer friendly 2025-11-19 00:03:03 -08:00
a14df54cd9 Added Tabs to edit page 2025-11-16 21:57:10 -08:00
bba62180fe fixed timezone 2025-11-16 14:54:33 -08:00
4c598340a8 clean up meta 2025-11-13 11:10:39 -08:00
6d7a7a5e9d 404 fixes and added pure twig 2025-11-13 11:05:55 -08:00
1cdf4f1fe8 Fixed new logo 2025-11-13 09:55:34 -08:00
12783d351c Sass Updates 2025-11-12 01:39:45 -08:00
39a14a759b Added sass 2025-11-10 18:54:16 -08:00
869c3a8d6a Edit Page fixes 2025-11-08 10:28:13 -08:00
a459b86169 added sitemap and docs 2025-11-07 14:45:13 -08:00
fb5407a60b fixed bugs for setup, added messages table 2025-10-08 23:34:17 -07:00
344786ee95 fixed up session class 2025-08-14 20:19:24 -07:00
103 changed files with 3209 additions and 466 deletions

View File

@@ -6,13 +6,20 @@ NovaconiumPHP is a high-performance PHP framework designed with inspiration from
Pronounced: Noh-vah-koh-nee-um Pronounced: Noh-vah-koh-nee-um
Packagist: https://packagist.org/packages/4lt/novaconium * Packagist: https://packagist.org/packages/4lt/novaconium
Master Repo: https://git.4lt.ca/4lt/novaconium * Master Repo: https://git.4lt.ca/4lt/novaconium
## Getting Started ## Getting Started
Novaconium is heavly influenced by docker, but you can use composer outside of docker. Novaconium is designed to be developed primarily using Docker. Instead of relying on tools installed directly on your system, common development tasks are executed inside containers:
You can [learn more about how novaconium works with composer](https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Install-Composer-On-Debian.md).
* Composer runs inside a Docker container rather than on your host machine
* Apache / PHP are served from containers instead of your local environment
* Sass compilation is also handled within a container
As long as you have Docker installed, you can use the full development environment without installing additional dependencies on your system.
You can [learn more about how novaconium works with composer](https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Composer.md).
```bash ```bash
PROJECTNAME=novaproject; PROJECTNAME=novaproject;
@@ -24,6 +31,9 @@ docker run --rm --interactive --tty --volume ./novaconium/:/app composer:latest
cp -R novaconium/vendor/4lt/novaconium/skeleton/. .; cp -R novaconium/vendor/4lt/novaconium/skeleton/. .;
# Edit .env # Edit .env
# pwgen -cnsB1v 12 # root password
# pwgen -cnsB1v 12 # mysql user password (need in both config and env)
# pwgen -cnsB1v 64 # framework key (need in config)
# Edit novaconium/App/config.php # Edit novaconium/App/config.php
docker compose up -d docker compose up -d
@@ -33,3 +43,23 @@ docker compose up -d
* [Novaconiumm Official Repo](https://git.4lt.ca/4lt/novaconium) * [Novaconiumm Official Repo](https://git.4lt.ca/4lt/novaconium)
* [CORXN Apache and PHP Container for Novaconium](https://git.4lt.ca/4lt/CORXN) * [CORXN Apache and PHP Container for Novaconium](https://git.4lt.ca/4lt/CORXN)
### How it works
#### htaccess
htaccess ensures that all requests are sent to index.php
#### index.php
index.php does two things:
1. Allows you to turn on error reporting (off by default)
2. Loads the novaconium bootstrap file novaconium.php
#### novaconium.php
What happens here:
1. Autoload composer
1. Loads configurations

View File

@@ -1,29 +1,28 @@
{ {
"name": "4lt/novaconium", "name": "4lt/novaconium",
"description": "A high-performance PHP framework built from the past.", "description": "A high-performance PHP framework built from the past.",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
{ {
"name": "Nick Yeoman", "name": "Nick Yeoman",
"email": "dev@4lt.ca", "email": "dev@4lt.ca",
"homepage": "https://www.4lt.ca", "homepage": "https://www.4lt.ca"
"role": "Consultant"
} }
], ],
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Novaconium\\\\": "src/" "Novaconium\\": "src/"
} }
}, },
"require": { "require": {
"twig/twig": "*", "php": "^8.1",
"nickyeoman/php-validation-class": "^5.0" "twig/twig": "*",
"nickyeoman/php-validation-class": "^5.0"
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"extra": { "extra": {
"versioning": { "versioning": {
"strategy": "semantic-versioning" "strategy": "semantic-versioning"
} }
} }
} }

View File

@@ -8,11 +8,14 @@ $framework_routes = [
], ],
'/novaconium/login' => [ '/novaconium/login' => [
'post' => 'NOVACONIUM/authenticate', 'post' => 'NOVACONIUM/authenticate',
'get' => 'NOVACONIUM/login' 'get' => 'NOVACONIUM/auth/login'
], ],
'/novaconium/dashboard' => [ '/novaconium/dashboard' => [
'get' => 'NOVACONIUM/dashboard' 'get' => 'NOVACONIUM/dashboard'
], ],
'/novaconium/settings' => [
'get' => 'NOVACONIUM/settings'
],
'/novaconium/pages' => [ '/novaconium/pages' => [
'get' => 'NOVACONIUM/pages' 'get' => 'NOVACONIUM/pages'
], ],
@@ -38,7 +41,13 @@ $framework_routes = [
'post' => 'NOVACONIUM/message_save' 'post' => 'NOVACONIUM/message_save'
], ],
'/novaconium/logout' => [ '/novaconium/logout' => [
'post' => 'NOVACONIUM/logout', 'post' => 'NOVACONIUM/auth/logout',
'get' => 'NOVACONIUM/logout' 'get' => 'NOVACONIUM/auth/logout'
] ],
'/novaconium/sitemap.xml' => [
'get' => 'NOVACONIUM/sitemap'
],
'/novaconium/sample/{slug}' => [
'get' => 'NOVACONIUM/samples'
],
]; ];

4
controllers/404.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
http_response_code('404');
header("Content-Type: text/html");
view('@novacore/404');

View File

@@ -0,0 +1,11 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Login Page',
'pageclass' => 'novaconium'
]);
// Don't come here if logged in
if ($session->get('username')) {
$redirect->url('/novaconium/dashboard');
makeitso();
}
view('@novacore/auth/login');

View File

@@ -0,0 +1,9 @@
<?php
$data = array_merge($data, [
'title' => 'Coming Soon',
'heading' => 'Coming Soon',
'countdown' => true,
'launch_date' => '2026-01-01T00:00:00'
]);
view('@novacore/coming-soon', $data);

View File

@@ -1,5 +1,7 @@
<?php <?php
// Create an admin user (POST)
use Nickyeoman\Validation; use Nickyeoman\Validation;
$validate = new Validation\Validate(); $validate = new Validation\Validate();

View File

@@ -1,7 +1,8 @@
<?php <?php
$data = array_merge($data, [ $data = array_merge($data, [
'title' => 'Novaconium Dashboard Page', 'title' => 'Novaconium Dashboard Page',
'pageclass' => 'novaconium' 'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]); ]);
if ( empty($session->get('username'))) { if ( empty($session->get('username'))) {

View File

@@ -2,7 +2,9 @@
$data = array_merge($data, [ $data = array_merge($data, [
'title' => 'Novaconium Edit Page', 'title' => 'Novaconium Edit Page',
'pageclass' => 'novaconium' 'pageclass' => 'novaconium',
'pageid' => 'controlPanel',
'editor' => 'ace'
]); ]);
// Check if logged in // Check if logged in
@@ -18,25 +20,35 @@ $pageid = $router->parameters['id'] ?? null;
if (!empty($pageid)) { if (!empty($pageid)) {
// Existing page: fetch from database // Existing page: fetch from database
$query = <<<EOSQL $query = <<<EOSQL
SELECT WITH all_tags AS (
id, SELECT GROUP_CONCAT(DISTINCT name ORDER BY name SEPARATOR ',') AS tags_list
title, FROM tags
heading, )
description, SELECT
keywords, p.id,
author, p.title,
slug, p.heading,
path, p.description,
intro, p.keywords,
body, p.author,
notes, p.slug,
draft, p.path,
changefreq, p.intro,
priority, p.body,
created, p.notes,
updated p.draft,
FROM pages p.changefreq,
WHERE id = ? p.priority,
p.created,
p.updated,
COALESCE(GROUP_CONCAT(DISTINCT t.name ORDER BY t.name SEPARATOR ','), '') AS page_tags,
at.tags_list AS existing_tags
FROM pages p
LEFT JOIN page_tags pt ON p.id = pt.page_id
LEFT JOIN tags t ON pt.tag_id = t.id
CROSS JOIN all_tags at -- Zero-cost join for scalar
WHERE p.id = ?
GROUP BY p.id;
EOSQL; EOSQL;
$data['rows'] = $db->getRow($query, [$pageid]); $data['rows'] = $db->getRow($query, [$pageid]);
@@ -50,7 +62,7 @@ EOSQL;
if (empty($pageid)) { if (empty($pageid)) {
// New page: set default values for all fields // New page: set default values for all fields
$data['rows'] = [ $data['rows'] = [
'id' => '', 'id' => 'newpage',
'title' => '', 'title' => '',
'heading' => '', 'heading' => '',
'description' => '', 'description' => '',
@@ -70,4 +82,4 @@ if (empty($pageid)) {
} }
// Render the edit page view // Render the edit page view
view('@novacore/editpage', $data); view('@novacore/editpage/index', $data);

View File

@@ -7,6 +7,7 @@ $data = [
'empty_users' => false, 'empty_users' => false,
'show_login' => false, 'show_login' => false,
'token' => $session->get('token'), 'token' => $session->get('token'),
'pageclass' => 'novaconium',
'title' => 'Novaconium Admin' 'title' => 'Novaconium Admin'
]; ];
@@ -103,13 +104,39 @@ if ($result->num_rows === 0) {
`updated` datetime DEFAULT NULL, `updated` datetime DEFAULT NULL,
`draft` tinyint(1) NOT NULL DEFAULT 1, `draft` tinyint(1) NOT NULL DEFAULT 1,
`changefreq` varchar(7) NOT NULL DEFAULT 'monthly', `changefreq` varchar(7) NOT NULL DEFAULT 'monthly',
`priority` float(4,1) NOT NULL DEFAULT 0.0 `priority` float(4,1) NOT NULL DEFAULT 0.0,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL; EOSQL;
$db->query($query); $db->query($query);
$log->info('Pages Table Created'); $log->info('Pages Table Created');
}
// Check ContactForm Table
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'contactForm';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE `contactForm` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`message` text DEFAULT NULL,
`created` datetime NOT NULL DEFAULT current_timestamp(),
`unread` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Unread is true by default',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('ContactForm Table Created');
} }
// Check if a user exists // Check if a user exists
@@ -125,4 +152,51 @@ if ($row['total'] < 1) {
makeitso(); makeitso();
} }
// Check Tags Table
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'tags';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE IF NOT EXISTS `tags` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL UNIQUE,
`created` datetime NOT NULL,
`updated` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('Tags Table Created');
}
// Check Page Tags Junction Table (after tags table)
$query = <<<EOSQL
SELECT TABLE_NAME
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'page_tags';
EOSQL;
$result = $db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE IF NOT EXISTS `page_tags` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`page_id` int(11) NOT NULL,
`tag_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `page_id` (`page_id`),
KEY `tag_id` (`tag_id`),
FOREIGN KEY (`page_id`) REFERENCES `pages` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('Page Tags Junction Table Created');
}
view('@novacore/init', $data); view('@novacore/init', $data);

View File

@@ -1,9 +0,0 @@
<?php
$data['title'] = 'Novaconium Login Page';
// Don't come here if logged in
if ($session->get('username')) {
$redirect->url('/novaconium/dashboard');
makeitso();
}
view('@novacore/login');

View File

@@ -2,7 +2,8 @@
$data = array_merge($data, [ $data = array_merge($data, [
'title' => 'Novaconium Messages', 'title' => 'Novaconium Messages',
'pageclass' => 'novaconium' 'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]); ]);
if ( empty($session->get('username'))) { if ( empty($session->get('username'))) {

View File

@@ -2,7 +2,8 @@
$data = array_merge($data, [ $data = array_merge($data, [
'title' => 'Novaconium Pages', 'title' => 'Novaconium Pages',
'pageclass' => 'novaconium' 'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]); ]);
if ( empty($session->get('username'))) { if ( empty($session->get('username'))) {

34
controllers/samples.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
/**
* Pure Twig, no db example
*
* Replicate Hugo but with html and twig (not markdown)
**/
// Variables
$pt = '@novacore/samples'; //Define the view directory
//$pt = 'samples'; //drop the core for your project
//Grab the slug
$slug = $router->parameters['slug'];
//build path
$tmpl = $pt . '/' . $slug;
//Check if file exits
$baseDir = (strpos($pt, 'novacore') !== false) ? FRAMEWORKPATH : BASEPATH;
if (strpos($pt, '@novacore') !== false) {
$baseDir = str_replace('@novacore', FRAMEWORKPATH . '/views', $pt);
} else {
$baseDir = str_replace('@novacore', BASEPATH . '/views', $pt);
}
$possibleFile = $baseDir . '/' . $slug . '.html.twig'; // add .twig extension if needed
if (is_file($possibleFile) && is_readable($possibleFile)) {
view($tmpl, $data);
} else {
http_response_code('404');
header("Content-Type: text/html");
view('@novacore/404');
}

View File

@@ -1,25 +1,33 @@
<?php <?php
use Nickyeoman\Validation; use Nickyeoman\Validation;
use Novaconium\Services\TagManager;
$v = new Nickyeoman\Validation\Validate(); $v = new Nickyeoman\Validation\Validate();
$url_error = '/novaconium/page/edit/' . $post->get('id'); // fallback for errors $url_error = '/novaconium/page/edit/' . $post->get('id'); // fallback for errors
// -------------------------
// Check login // Check login
// -------------------------
if (empty($session->get('username'))) { if (empty($session->get('username'))) {
$messages->error('You are not logged in'); $messages->error('You are not logged in');
$redirect->url('/novaconium/login'); $redirect->url('/novaconium/login');
makeitso(); makeitso();
} }
// Check token // -------------------------
// Check CSRF token
// -------------------------
if ($session->get('token') != $post->get('token')) { if ($session->get('token') != $post->get('token')) {
$messages->error('Invalid Token'); $messages->error('Invalid Token');
$redirect->url('/novaconium/pages'); $redirect->url('/novaconium/pages');
makeitso(); makeitso();
} }
// -------------------------
// Gather POST data // Gather POST data
// -------------------------
$id = $post->get('id'); $id = $post->get('id');
$title = $_POST['title'] ?? ''; $title = $_POST['title'] ?? '';
$heading = $_POST['heading'] ?? ''; $heading = $_POST['heading'] ?? '';
@@ -34,8 +42,20 @@ $notes = $_POST['notes'] ?? '';
$draft = !empty($post->get('draft')) ? 1 : 0; $draft = !empty($post->get('draft')) ? 1 : 0;
$changefreq = $_POST['changefreq'] ?? 'monthly'; $changefreq = $_POST['changefreq'] ?? 'monthly';
$priority = $_POST['priority'] ?? 0.0; $priority = $_POST['priority'] ?? 0.0;
$tags_json = $_POST['tags_json'] ?? '[]';
// -------------------------
// Decode & sanitize tags
// -------------------------
$tags = json_decode($tags_json, true);
if (!is_array($tags)) $tags = [];
$tags = array_map('trim', $tags);
$tags = array_filter($tags, fn($t) => $t !== '');
$tags = array_unique($tags);
// -------------------------
// Validate required fields // Validate required fields
// -------------------------
if (empty($title) || empty($slug) || empty($body)) { if (empty($title) || empty($slug) || empty($body)) {
$messages->error('Title, Slug, and Body are required.'); $messages->error('Title, Slug, and Body are required.');
$redirect->url($url_error); $redirect->url($url_error);
@@ -43,8 +63,30 @@ if (empty($title) || empty($slug) || empty($body)) {
} }
try { try {
if (!empty($id)) { $tagManager = new TagManager();
if ($id == 'newpage') {
// -------------------------
// Create new page
// -------------------------
$query = "INSERT INTO `pages`
(`title`, `heading`, `description`, `keywords`, `author`,
`slug`, `path`, `intro`, `body`, `notes`,
`draft`, `changefreq`, `priority`, `created`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())";
$params = [
$title, $heading, $description, $keywords, $author,
$slug, $path, $intro, $body, $notes,
$draft, $changefreq, $priority
];
$db->query($query, $params);
$id = $db->lastid;
$messages->notice('Page Created');
} else {
// -------------------------
// Update existing page // Update existing page
// -------------------------
$query = "UPDATE `pages` SET $query = "UPDATE `pages` SET
`title` = ?, `heading` = ?, `description` = ?, `keywords` = ?, `author` = ?, `title` = ?, `heading` = ?, `description` = ?, `keywords` = ?, `author` = ?,
`slug` = ?, `path` = ?, `intro` = ?, `body` = ?, `notes` = ?, `slug` = ?, `path` = ?, `intro` = ?, `body` = ?, `notes` = ?,
@@ -57,27 +99,18 @@ try {
]; ];
$db->query($query, $params); $db->query($query, $params);
$messages->notice('Page Updated'); $messages->notice('Page Updated');
} else {
// Create new page
$query = "INSERT INTO `pages`
(`title`, `heading`, `description`, `keywords`, `author`,
`slug`, `path`, `intro`, `body`, `notes`,
`draft`, `changefreq`, `priority`, `created`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())";
$params = [
$title, $heading, $description, $keywords, $author,
$slug, $path, $intro, $body, $notes,
$draft, $changefreq, $priority
];
$db->query($query, $params);
$id = $db->lastid; // Get new page ID
$messages->notice('Page Created');
} }
// -------------------------
// Save tags (for both new and existing pages)
// -------------------------
$tagManager->setTagsForPage($id, $tags);
} catch (Exception $e) { } catch (Exception $e) {
$messages->error($e->getMessage()); $messages->error($e->getMessage());
$redirect->url($url_error); $redirect->url($url_error);
makeitso(); makeitso();
} }
// Redirect to edit page // Redirect back to edit page
$redirect->url('/novaconium/page/edit/' . $id); $redirect->url('/novaconium/page/edit/' . $id);

15
controllers/settings.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
$data = array_merge($data, [
'title' => 'Novaconium Settings',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
makeitso();
}
view('@novacore/settings', $data);

42
controllers/sitemap.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
header('Content-Type: text/xml');
// https://www.sitemaps.org/protocol.html
// Check it here: https://www.mysitemapgenerator.com/service/check.html
$query=<<<EOSQL
SELECT draft, slug, updated, changefreq, priority, path
FROM pages
WHERE priority > 0
AND draft = 0
ORDER BY updated DESC;
EOSQL;
$thepages = $db->getRows($query);
// Start the view
echo '<?xml version="1.0" encoding="UTF-8"?>';
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
// Loop through the pages
if ( ! empty($thepages) ) {
foreach( $thepages as $v) {
$date = (new \DateTime($v['updated']))->format('Y-m-d');
echo "<url>";
if ( empty($v['path']) )
echo "<loc>" . $config['base_url'] . '/page/' . $v['slug'] . "</loc>";
else
echo "<loc>" . $config['base_url'] . $v['path'] . "</loc>";
echo "<lastmod>" . $date . "</lastmod>";
echo "<changefreq>" . $v['changefreq'] . "</changefreq>";
echo "<priority>" . sprintf("%.1f", $v['priority']) . "</priority>";
echo "</url>";
}
} else {
echo "no pages added yet";
}
echo "</urlset>";

6
docs/404.md Normal file
View File

@@ -0,0 +1,6 @@
# 404 Page
404 page is created like any other page.
Create a 404.php in your controllers and a 404.html.twig in your views.
anytime a resource is not found by the router, it will default to this controller.
if you do not have this controller in your app, it will default to the novaconium 404 page.

View File

@@ -8,7 +8,7 @@ Update novaconium with composer in docker: ```docker run --rm --interactive --tt
## Install Composer natively on Debian ## Install Composer natively on Debian
Assuming you have nala installed: Assuming you have nala installed (otherwise use apt-get):
```bash ```bash
sudo nala install curl php-cli php-mbstring git unzip sudo nala install curl php-cli php-mbstring git unzip

52
docs/ConfigurationFile.md Normal file
View File

@@ -0,0 +1,52 @@
# Configuration File
## App/config.php
The configuration file holds a php multi dimentional array for configuration.
#### database
This is the connection setting for mariadb.
```
'database' => [
'host' => 'ny-db',
'name' => 'nydb',
'user' => 'nydbu',
'pass' => 'as7!d5fLKJ2DLKJS5',
'port' => 3306
],
```
#### base_url
Defines the url to use
```
'base_url' => 'https://www.nickyeoman.com',
```
#### secure_key
The security key is used to verify admin account and salt encrpytion functions.
You can generate a key with ```pwgen -cnsB1v 64```
but if you don't set one, novaconium will generate one for you to use (you have to explicily set it though).
```
'secure_key' => '',
```
#### logfile
sets the path of the log file.
```
'logfile' => '/logs/novaconium.log',
```
#### loglevel
Sets the logging level for the app.
```
'loglevel' => 'ERROR' // 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'NONE'
```

18
docs/Dev-Fake_autoload.md Normal file
View File

@@ -0,0 +1,18 @@
# Fake autoload for dev
put this in index.php
```
// --- Dev-only autoloader for manually cloned vendor copy ---
spl_autoload_register(function ($class) {
if (str_starts_with($class, 'Novaconium\\')) {
$baseDir = BASEPATH . '/vendor/4lt/novaconium/src/';
$relativeClass = substr($class, strlen('Novaconium\\'));
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require_once $file;
}
}
});
```

View File

@@ -3,7 +3,7 @@
You can use the logging class to output to a file. You can use the logging class to output to a file.
use ```$log->info(The Message');```1 use ```$log->info(The Message');```
Logging levels are: Logging levels are:
``` ```
@@ -16,4 +16,4 @@ Logging levels are:
It's recommended that production is set to ERROR. It's recommended that production is set to ERROR.
You set the log level in /App/config.php under 'loglevel' => 'ERROR' You set the log level in /App/config.php under 'loglevel' => 'ERROR'
If you are using CORXN a health check is run every 30 seconds which would fill the log file with info.

101
docs/Router.md Normal file
View File

@@ -0,0 +1,101 @@
# Router
`Novaconium\Router` resolves the current HTTP request to a controller file, based on a route table defined in PHP config files. It supports exact-match routes and simple parameterized routes (e.g. `/users/{id}`).
## How it works
On construction, the router runs through this pipeline:
1. **Load routes** — merges framework-level routes with app-level routes.
2. **Prepare path** — normalizes `REQUEST_URI` into a clean path string.
3. **Prepare query** — parses the query string into an array.
4. **Determine request type**`get` or `post`.
5. **Find controller** — matches the path against the route table.
6. **Resolve controller file** — turns the matched controller string into an actual file path, falling back to a 404 controller if needed.
## Properties
| Property | Type | Description |
|---|---|---|
| `$routes` | array | Combined route table (framework + app routes), keyed by path pattern. |
| `$query` | array | Parsed query string parameters. |
| `$path` | string | Normalized request path. |
| `$controller` | string | Resolved controller identifier (e.g. `Users/show` or `NOVACONIUM/Errors`). |
| `$controllerPath` | string | Absolute file path to the controller. |
| `$parameters` | array | Named parameters extracted from a parameterized route match. |
| `$requestType` | string | `'get'` or `'post'`. |
## Route table format
Routes are defined as an associative array, keyed by URL path. Each entry maps HTTP methods to a controller string:
```php
$routes = [
'/' => [
'get' => 'Home/index',
],
'/users/{id}' => [
'get' => 'Users/show',
],
'/login' => [
'get' => 'Auth/loginForm',
'post' => 'Auth/login',
],
];
```
Controller strings starting with `NOVACONIUM` are resolved against the framework's controller directory (e.g. `NOVACONIUM/Errors``FRAMEWORKPATH/controllers/Errors.php`); all other controller strings are resolved against the app's controller directory (`BASEPATH/App/controllers/`).
## Matching behavior
### 1. Exact match
The router first checks for an exact match between `$this->path` and a key in `$this->routes`. If found, and the current request type has a handler defined, that controller is returned immediately.
### 2. Parameterized match
If no exact match is found, the router scans the route table for patterns containing `{`. For each candidate:
- The **static prefix** of the pattern (everything before the first `{`) must prefix-match the current path.
- The pattern and the path must have the **same number of `/`-separated segments**.
The first route pattern satisfying both conditions is treated as the match — the router does not keep searching for a "better" match after this point, even if the matched route turns out to have no handler for the current request method (see [Known quirks](#known-quirks) below).
Once a candidate route is selected, the router walks its segments looking for the first `{param}` segment, extracts the corresponding path segment into `$this->parameters[paramName]`, and returns immediately with the controller for the current request type.
### 3. No match
If neither an exact nor a parameterized match is found, `$this->controller` is set to `'404'`, and `setRouteFile()` will fail to find a corresponding controller file, triggering the 404 fallback described below.
## Controller file resolution (`setRouteFile`)
Given the resolved `$this->controller` string:
1. If it starts with `NOVACONIUM`, look in `FRAMEWORKPATH/controllers/`.
2. Otherwise, look in `BASEPATH/App/controllers/`.
3. If the file exists, use it.
4. If not, fall back to `BASEPATH/App/controllers/404.php` if it exists.
5. Otherwise, fall back to the framework's built-in `FRAMEWORKPATH/controllers/404.php`.
## Known quirks
These behaviors exist in the current implementation and are preserved intentionally for backward compatibility — they're documented here so they aren't mistaken for bugs during future changes.
- **Only the first `{param}` in a route pattern is captured.** If a route pattern has multiple parameters (e.g. `/users/{id}/posts/{postId}`), only `id` is extracted into `$this->parameters`; `postId` is never processed, because the matching loop returns as soon as it finds the first parameter segment.
- **The first prefix+segment-count match wins, even without a method handler.** If a parameterized route's static prefix and segment count match the request, but that route has no handler defined for the current request type (e.g. the route only defines `get` but the request is `post`), the router still commits to that route and returns `null` as the controller (which then falls through to the 404 controller). It does **not** continue searching for another route that might have a valid handler.
- **No support for trailing wildcard or optional segments.** Segment counts must match exactly between the pattern and the path; there's no support for `*` or optional `{param?}` style segments.
## Debugging
Call `$router->debug()` to dump the resolved path, controller path, extracted parameters, and full route table as an HTML table, then halt execution via `die()`. Intended for development use only.
## Example
```php
$router = new \Novaconium\Router();
include $router->controllerPath;
// Inside the controller, access route parameters via:
$router->parameters['id'];
```

33
docs/Sass.md Normal file
View File

@@ -0,0 +1,33 @@
# Sass
## Docker
There is a dockerfile in the sass directory you can build an image with.
```bash
cd sass
docker build -t sass-container .
```
## Running Sass
```bash
sudo docker run --rm -v $(pwd):/usr/src/app -w /usr/src/app sass-container sass novaconium/sass/project.sass novaconium/public/css/novaconium.css
```
Compressed:
```bash
# Build Novaconium (compressed)
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app sass-container --style=compressed sass/novaconium.sass skeleton/novaconium/public/css/novaconium.css
```
Dev:
```bash
docker run --rm \
-v "$(pwd)/sass:/usr/src/sass" \
-v "/home/nick/tmp/novaproject/novaconium/public/css:/usr/src/css" \
-w /usr/src \
sass-container \
sass sass/novaconium.sass css/novaconium.css --no-source-map --style=compressed
```

12
sass/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
# Use an official Node.js image as a base
FROM node:16-slim
# Install Sass globally
RUN npm install -g sass \
&& npm cache clean --force
# Set working directory inside container
WORKDIR /usr/src/app
# Set entrypoint to Sass CLI
ENTRYPOINT ["sass"]

View File

@@ -0,0 +1,80 @@
// abstracts/_variables.sass (updated)
@use 'sass:map'
// Color palette (Sass map: HSL for modularity; all lightnesses darkened ~10-20%)
$colors: (
'bg-dark': hsl(0, 0%, 2%),
'bg-darker': hsl(210, 7%, 5%),
'text-light': hsla(0, 0%, 100%, 1.00),
'text-lighter': hsl(120, 52%, 15%),
'text-muted': hsl(120, 40%, 45%),
'accent-light': hsl(120, 100%, 35%),
'accent-success': hsl(120, 80%, 45%),
'accent-warning': hsl(60, 100%, 35%),
'accent-error': hsl(0, 100%, 45%),
'border-light': hsla(120, 60%, 70%, 0.2)
)
// Helper functions to pull from map
@function color($key)
@return map.get($colors, $key)
// Direct vars for common use
$bg-dark: color('bg-dark') !default
$bg-darker: color('bg-darker') !default
$text-light: color('text-light') !default
$text-lighter: color('text-lighter') !default
$text-muted: color('text-muted') !default
$accent-light: color('accent-light') !default
$accent-success: color('accent-success') !default
$accent-warning: color('accent-warning') !default
$accent-error: color('accent-error') !default
$border-light: color('border-light') !default
$font-stack: 'Courier New', 'SF Mono', monospace
$mono-font: 'Courier New', 'SF Mono', 'Fira Code', Consolas, monospace
$spacing: (
'xs': 0.25rem,
'sm': 0.5rem,
'md': 1rem,
'lg': 1.5rem,
'xl': 2.5rem,
'2xl': 4rem
)
@function space($key)
@return map.get($spacing, $key)
$breakpoints: (
'sm': 640px,
'md': 768px,
'lg': 1024px,
'xl': 1280px,
'2xl': 1536px
)
$z-index: (
'dropdown': 1000,
'sticky': 50,
'modal': 2000
)
@function z($key)
@return map.get($z-index, $key)
$dark-mode: true !default
:root
--bg-dark: #{color('bg-dark')}
--bg-darker: #{color('bg-darker')}
--text-light: #{color('text-light')}
--text-lighter: #{color('text-lighter')}
--text-muted: #{color('text-muted')}
--accent-light: #{color('accent-light')}
--accent-success: #{color('accent-success')}
--accent-warning: #{color('accent-warning')}
--accent-error: #{color('accent-error')}
--border-light: #{color('border-light')}
--font-stack: #{$font-stack}

View File

@@ -0,0 +1 @@
@forward 'variables';

File diff suppressed because one or more lines are too long

178
sass/base/_reset.sass Normal file
View File

@@ -0,0 +1,178 @@
// base/_reset.sass
@use '../abstracts' as *
// Global box-sizing and resets
*
box-sizing: border-box
html
font-size: 16px // Base font size
line-height: 1.5
scroll-behavior: smooth
background-color: $bg-dark // Dark bg
color: $text-light // Light text
body
margin: 0
padding: 0
font-family: $font-stack // e.g., -apple-system, sans-serif
background-color: $bg-dark
color: $text-light
min-height: 100vh
// Headings: Light, bold, with spacing
h1, h2, h3, h4, h5, h6
margin: 0 0 0.5em
font-weight: 600
line-height: 1.2
color: $accent-light
h1
font-size: 2.5rem
h2
font-size: 2rem
h3
font-size: 1.75rem
h4
font-size: 1.5rem
h5
font-size: 1.25rem
h6
font-size: 1rem
// Paragraphs and text
p
margin: 0 0 1em
small
font-size: 0.875rem
strong, b
font-weight: 700
em, i
font-style: italic
// Links: Light with hover underline
a
color: $accent-light
text-decoration: none
border-bottom: 1px solid transparent
transition: border-color 0.2s ease
&:hover, &:focus
border-bottom-color: $accent-light
outline: none
// Lists
ul, ol
margin: 0 0 1em
padding-left: 1.5em
li
margin-bottom: 0.25em
// Blockquote
blockquote
margin: 1em 0
padding: 0.5em 1em
border-left: 4px solid $accent-light
background-color: rgba($bg-dark, 0.5)
color: $text-light
// Code and pre
code, kbd, samp
font-family: $mono-font
font-size: 0.875em
background-color: rgba($text-light, 0.1)
padding: 0.125em 0.25em
border-radius: 3px
pre
margin: 1em 0
padding: 1em
overflow: auto
background-color: rgba($text-light, 0.1)
border-radius: 4px
code
background: none
padding: 0
// Tables
table
width: 100%
border-collapse: collapse
margin: 1em 0
th, td
padding: 0.75em
text-align: left
border-bottom: 1px solid rgba($text-light, 0.2)
th
font-weight: 600
// Forms
input, select, textarea, button
font-family: inherit
font-size: inherit
background-color: rgba($text-light, 0.1)
color: $text-light
border: 1px solid rgba($text-light, 0.3)
border-radius: 4px
padding: 0.5em
&:focus
outline: none
border-color: $accent-light
box-shadow: 0 0 0 2px rgba($accent-light, 0.2)
button
cursor: pointer
transition: background-color 0.2s ease
&:disabled
opacity: 0.5
cursor: not-allowed
// Images and media
img
max-width: 100%
height: auto
border-radius: 4px // Optional subtle rounding
figure
margin: 1em 0
// HR
hr
border: none
height: 1px
background-color: rgba($text-light, 0.2)
margin: 2em 0
// Accessibility: Focus visible for all interactive elements
*:focus-visible
outline: 2px solid $accent-light
outline-offset: 2px
// Print styles (optional)
@media print
body
background: white !important
color: black !important
::selection
background: rgba($accent-light, 0.3) // Subtle accent highlight for that terminal select vibe
color: $text-light // Crisp text contrast
// Optional: Vendor prefixes for broader support (though modern browsers are solid)
*::-moz-selection
background: rgba($accent-light, 0.3)
color: $text-light

2
sass/base/index.sass Normal file
View File

@@ -0,0 +1,2 @@
@forward 'reset';
@forward 'background';

View File

@@ -0,0 +1,94 @@
body#coming-soon
display: flex
justify-content: center
align-items: center
height: 100%
padding: 1rem
box-sizing: border-box
margin: 0
text-align: center
color: #eee
font-family: 'Segoe UI', Roboto, sans-serif
background-size: cover
background-repeat: no-repeat
background-position: center center
background-attachment: fixed
.container
max-width: 600px
background-color: rgba(30, 30, 30, 0.89)
padding: 2rem
border-radius: 8px
box-shadow: 0 0 20px rgba(0,0,0,0.5)
h1
font-size: 3rem
margin-bottom: 1rem
animation: pulse 1.5s infinite
p
font-size: 1.2rem
opacity: 0.85
margin-bottom: 1.5rem
.countdown
font-size: 2rem
font-weight: bold
margin: 1rem 0 2rem 0
form.newsletter
display: flex
flex-direction: column
gap: 0.5rem
margin-top: 1rem
input[type="email"]
padding: 0.5rem
font-size: 1rem
border: 1px solid #666
border-radius: 4px
background: rgba(255,255,255,0.05)
color: #eee
button
padding: 0.5rem
font-size: 1rem
border: 1px solid #1e90ff
border-radius: 4px
background: #1e90ff
color: #fff
cursor: pointer
transition: background 0.2s
&:hover
background: #1565c0
.social-icons
margin-top: 2rem
display: flex
justify-content: center
gap: 1rem
a
display: inline-flex
align-items: center
justify-content: center
width: 40px
height: 40px
font-size: 1.5rem
color: #eee
text-decoration: none
transition: color 0.2s
&:hover
color: #1e90ff
i
font-style: normal
@keyframes pulse
0%, 100%
opacity: 0.8
50%
opacity: 1

View File

@@ -0,0 +1,4 @@
body#controlPanel
footer
padding: 1rem

View File

@@ -0,0 +1,8 @@
body#controlPanel
header
padding: 0
h1#biglogo
padding: 10px 0 0 0
margin: 0

View File

@@ -0,0 +1,11 @@
body#controlPanel
div#panel
main#content
flex: 1
padding: 20px 40px
min-width: 0
background-color: #0b0b0bd6
border-left: 2px solid #104910

View File

@@ -0,0 +1,22 @@
@use '../abstracts' as *
body#controlPanel
div#panel
div#cp-menu
background-color: #000
width: 360px
padding: 0
white-space: nowrap
ul#cp-nav
list-style: none
margin: 0
padding: 20px 0 0 0
li a
display: block
border-bottom: 1px solid $border-light
padding: 4px 10px
&:hover
background-color: #222

View File

@@ -0,0 +1,5 @@
body#controlPanel
div#panel
display: flex
flex-direction: row

View File

@@ -0,0 +1,10 @@
@forward 'header';
@forward 'panel';
@forward 'menu';
@forward 'main';
@forward 'footer';
body#controlPanel
h1
font-family: "VT323", "Courier New", "SF Mono", "Fira Code", Consolas, monospace
font-size: 48px

29
sass/framework/_ace.sass Normal file
View File

@@ -0,0 +1,29 @@
@use '../abstracts' as *
@use 'sass:color' // For color.adjust()non-deprecated color tweaks
@use '../base' as *
// ACE Editor
.editor-container
width: 100%
min-height: 400px // ~10 rows
.ace-editor
height: 400px // Scrollable
border: 1px solid $accent-light
font-family: $mono-font // Your monospace
// Ace internals (dark theme tweaks)
.ace_gutter
background: $bg-darker !important
color: $text-muted !important
border-right: 1px solid $accent-light !important
.ace_scroller
background: $bg-dark !important
.ace_text-layer .ace_print-margin
background: transparent !important
// Selection
.ace_marker-layer .ace_selection
background: color.adjust($accent-light, $alpha: -0.3) !important // Green tint

View File

@@ -0,0 +1,15 @@
@use '../abstracts' as *
@use '../base' as *
@use 'sass:color' // Already there for adjusts
#edit-page-title
margin-top: 2rem
#title
width: 100%
font-size: 1.9rem
padding: 20px
&>div
font-size: 0.8rem
margin: 8px 0 0 20px

View File

@@ -0,0 +1,62 @@
// pages/_forms.sass
@use '../abstracts' as *
@use '../base' as *
body.novaconium
#edit-page-form-novaconium
// Form groups: Spacing
.form-group
margin-bottom: space('md') // 1rem
&.fullwidth textarea
width: 100%
resize: vertical
.checkbox-group
margin-top: space('lg') // 1.5rem
// Inputs: Dark bg, green focus
input[type="text"],
input[type="number"],
textarea,
select
background-color: color('bg-darker') // Deeper green-black
border: 1px solid color('border-light')
color: $text-light
padding: space('sm')
border-radius: 4px
font-family: 'VT323', $mono-font // Matrix monospace
&:focus
border-color: $accent-light // Lime focus
box-shadow: 0 0 0 space('xs') rgba($accent-light, 0.3) // Glow ring
outline: none
input[type="checkbox"]
accent-color: $accent-light // Green checkbox
// Submit button: Green primary
button[type="submit"]
background-color: $accent-light
color: color('bg-dark') // Dark text on green
border: none
padding: space('sm') space('md')
border-radius: 4px
cursor: pointer
font-family: 'VT323', $mono-font
&:hover, &:focus
background-color: hsl(120, 100%, 30%) // Darker green (35% L -5%)
box-shadow: 0 0 4px rgba($accent-light, 0.5)
// Text/links: Muted with hover
p
color: $text-muted
a
color: $accent-light
&:hover
color: $text-lighter // Brighter green
text-decoration: underline

View File

@@ -0,0 +1,53 @@
// framework/_login_form.sass
@use '../abstracts' as *
body.novaconium #login
// No background or borderuse parent's ambient styling
padding: 0 // Or keep minimal if needed; adjust to 2rem if you want internal space
// No centering: left-aligned by default
// Wrapper for inputs/button to align widths
input[type="text"],
input[type="password"],
button[type="submit"]
width: 100% // Full width of form (no max cap for flow)
max-width: 300px // Keep cap for consistency across fields
padding: 1rem
margin-bottom: 1rem
box-sizing: border-box // Include padding in width
font-size: 1rem
border: 1px solid $border-light
border-radius: 4px
background: $bg-dark
color: $text-light
// Icon backgrounds: Cyber icons via data URI, now in white for visibility
input[type="text"] // Username input
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E")
background-repeat: no-repeat
background-position: 1rem center
background-size: 20px
padding-left: 3rem // Space for icon
input[type="password"] // Password input
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='11' width='18' height='11' rx='2' ry='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E")
background-repeat: no-repeat
background-position: 1rem center
background-size: 20px
padding-left: 3rem // Space for icon
button[type="submit"]
background: $accent-light // Green accent for submit
border-color: $accent-light
cursor: pointer
transition: background 0.2s
&:hover
background: $accent-light
text-shadow: 0 0 5px rgba($accent-light, 0.5) // Cyber glow
// No icon needed for button, but add if you want (e.g., arrow)
&::after
content: '' // Simple arrow, or swap for Material Icon
margin-left: 0.5rem

79
sass/framework/_logo.sass Normal file
View File

@@ -0,0 +1,79 @@
body.novaconium
#biglogo
position: relative
display: inline-block
padding: 20px 0
border-radius: 0 // Keep it sharp, no curves
overflow: hidden
&::before
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background-image: linear-gradient(rgba(0, 255, 0, 0.02) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 255, 0, 0.02) 1px, transparent 1px)
background-size: 20px 20px
opacity: 0.1 // Subtle grid/matrix rain hint
pointer-events: none
// Scan lines for extra flicker
&::after
content: ''
position: absolute
top: 0
left: 0
right: 0
height: 1px
background: linear-gradient(90deg, transparent, #00ff00, transparent)
opacity: 0.05
animation: scan 3s linear infinite
.main
display: block
font-family: 'Orbitron', sans-serif // Cyberpunk geometric punch
font-size: 4.5rem // Chunky figlet scale
font-weight: 900 // Black weight for max angular depth
color: #fff
letter-spacing: 0.1em // Slightly looser for Orbitron's geometry
line-height: 0.85 // Compact height like figlet blocks
text-transform: uppercase
// Multi-layer shadow to mimic ansishadow's depth: base shadow + edge glow
text-shadow: 4px 4px 0 #222, 5px 5px 0 #111, -1px -1px 0 #00ff00, 1px 1px 20px rgba(0, 255, 0, 0.3) // Main drop shadow, deeper offset, subtle green edge highlight for cyber pop, glow for security flair
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8)) // Browser shadow boost
.sub
display: block
font-family: 'Orbitron', sans-serif // Match for cohesion, or swap to monospace if you want contrast
font-size: 2.5rem
font-weight: 700 // Bold but not overpowering
color: #fff
letter-spacing: 0.15em // Wider for emphasis
margin-top: 0.5rem
text-shadow: 2px 2px 0 #222, -1px -1px 0 #00ff00, 1px 1px 10px rgba(0, 255, 0, 0.2) // Lighter shadow, subtle green edge, glow
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.6))
// Optional: Glitch animation on hover for that hacked feel
&:hover
.main
animation: glitch 0.3s infinite
@keyframes scan
0%
top: -1px
100%
top: 100%
@keyframes glitch
0%, 100%
transform: translate(0)
20%
transform: translate(-2px, 2px)
40%
transform: translate(-2px, -2px)
60%
transform: translate(2px, 2px)
80%
transform: translate(2px, -2px)

62
sass/framework/_main.sass Normal file
View File

@@ -0,0 +1,62 @@
// layout/_main.sass
@use '../abstracts' as *
@use '../base' as *
body.novaconium
// Main page wrapper: Centered flex
#page .container
display: flex
padding: 0
justify-content: center
align-items: flex-start
padding-top: space('lg')
// Article content: Fixed width, no shrink
article
width: 1140px
flex-shrink: 0
padding: 3rem
margin: 0
background-color: $bg-dark
border: 1px solid $bg-darker
ul#leftnav
width: 320px
flex-shrink: 0
list-style: none
padding: 0
margin: 0 space('xl') 0 0 // Right margin preserved (overrides the 0 for other sides)
background-color: $bg-dark
border: 1px solid $bg-darker
#leftnav li
border-bottom: 1px solid color('border-light')
&:last-child
border-bottom: none
#leftnav a
display: block
padding: space('sm') space('md') // Consistent spacing
text-decoration: none
color: $text-light // Theme text
&:hover, &:focus
background-color: rgba($accent-light, 0.1) // Green hover tint
color: $accent-light // Accent on hover
border-radius: 4px
// Responsive: Stack on mobile (add to themes/ for media queries)
@media (max-width: 1280px) // Your xl breakpoint
#page .container
flex-direction: column
align-items: center
article, #leftnav
width: 100%
max-width: 900px
margin: space('md') 0
#leftnav
margin-right: 0

49
sass/framework/_tabs.sass Normal file
View File

@@ -0,0 +1,49 @@
// framework/_tabs.sass
@use '../abstracts' as *
@use 'sass:color' // For color.adjust()non-deprecated color tweaks
body#controlPanel
// Simplified tab styling: Square borders, no rounds
.tab-container
margin: space('lg') auto
background: $bg-dark
.tab-nav
display: flex
background-color: $bg-darker
border-bottom: 1px solid $border-light
.tab-button
padding: space('sm') space('md')
background: $bg-darker
border: 1px solid $border-light
border-bottom: none
cursor: pointer
font-size: 14px
min-width: 120px // Makes them more square/equal
text-align: center
color: $text-light
font-family: $mono-font // From vars
&:hover
background-color: color.adjust($bg-darker, $lightness: 5%) // Modern, non-deprecated lighten equivalent
+ .tab-button
border-left: none // Shared borders
&.active
background-color: $bg-dark
border-bottom: 2px solid $accent-light
color: $accent-light
font-weight: bold
.tab-content
display: none
padding: space('lg')
background-color: $bg-dark
color: $text-light
border: none
&.active
display: block

109
sass/framework/_tags.sass Normal file
View File

@@ -0,0 +1,109 @@
@use '../abstracts' as *
@use 'sass:color' // For color.adjust()non-deprecated color tweaks
@use '../base' as *
body.novaconium
// Tags Dropdown
.tags-dropdown
position: absolute
top: 100%
left: 0
right: 0
max-height: 200px
overflow-y: auto
background: $bg-darker
border: 1px solid $border-light
border-top: none
border-radius: 0 0 4px 4px
list-style: none
margin: 0
padding: 0
z-index: 10
opacity: 0
visibility: hidden
transition: opacity 0.2s
&[aria-expanded="true"]
opacity: 1
visibility: visible
li
padding: space('xs') space('sm')
cursor: pointer
color: $text-light
font-size: 14px
border-bottom: 1px solid transparent
&:hover,
&.selected
background: color.adjust($accent-light, $alpha: -0.1)
color: $accent-light
&:last-child
border-bottom: none
.tags-input
position: relative // For absolute dropdown
// Tags Input
.tags-input
display: flex
flex-wrap: wrap
gap: space('xs')
align-items: center
border: 1px solid $border-light
padding: space('xs')
background: $bg-darker
min-height: 40px
grid-column: 2 // Spans input area in .form-row grid
input
border: none
background: transparent
color: $text-light
font-family: $mono-font
font-size: 14px
flex: 1
min-width: 100px
outline: none
&::placeholder
color: $text-muted
.tag-chip
display: inline-flex
align-items: center
background: color.adjust($accent-light, $alpha: -0.2)
color: #fff
padding: space('xs') space('sm')
border-radius: 4px
font-size: 12px
max-width: 150px
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.tag-remove
background: none
border: none
color: inherit
font-size: 16px
margin-left: space('xs')
cursor: pointer
padding: 0
line-height: 1
&:hover
opacity: 0.7
// Tags Autocomplete (native datalist styling)
.tags-input input[list]
// Existing input styles apply...
#tag-suggestions
// Native datalist not directly stylable, but options can be influenced via input focus
// For custom polyfill, add JS-generated <ul>, but native is fine for now
// Browser-specific tweaks (Chrome/Firefox)
.tags-input input[list]::-webkit-calendar-picker-indicator
display: none // Hide arrow if interfering

View File

@@ -0,0 +1,48 @@
@use '../abstracts' as *
@use '../base' as *
@use 'sass:color' // Already there for adjusts
body.novaconium
// Tooltip styling (mouse-follow version)
.tooltip
position: relative
display: inline-block
cursor: help
font-size: 16px
color: $accent-light // Green accent for ?
margin-left: space('xs') // 0.25rem
top: 2px
.tooltiptext
visibility: hidden
width: 200px
background-color: $bg-darker // Darker bg
color: $text-light // White text
text-align: left // Changed to left-align for better readability
border-radius: 0 // Square
padding: space('sm') // 0.5rem
position: fixed // Fixed to viewport, updated by JS
z-index: z('dropdown') // High z-index
opacity: 0
transition: opacity 0.3s
font-size: 14px
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) // Subtle shadow
pointer-events: none // Prevents mouse interference with tooltip
&::after
content: ""
position: absolute
top: -5px // Arrow above tooltip (points up to mouse)
left: 10px // Slight indent from left edge
border-width: 5px
border-style: solid
border-color: $bg-darker transparent transparent transparent // Upward arrow
&:hover .tooltiptext
visibility: visible
opacity: 1
// Accessibility: Hidden tooltip text for screen readers
.tooltip-desc
position: absolute
left: -9999px

59
sass/framework/_ui.sass Normal file
View File

@@ -0,0 +1,59 @@
// components/_ui.sass
@use '../abstracts' as *
@use '../base' as *
body.novaconium
// Code blocks: Monospace lock-in with darker green tint
code
font-family: 'VT323', $mono-font // Prioritize Matrix font
font-size: 0.875rem // Smaller for inline; reset has base
background-color: color('bg-darker') // Deeper green-black
color: $text-light // Lighter green text
padding: space('xs') space('sm')
border-radius: 4px
border: 1px solid color('border-light')
box-shadow: 0 0 2px rgba($accent-light, 0.1) // Subtle glow
// Utility: Small text (if still needed; consider rem-based)
.small
font-size: 0.625rem // 10px equiv; use sparingly
// Error/notice divs: Centered alerts with theme colors
div.error, div.notice, div#debug
margin: space('xl') auto // Top/bottom spacing from map
width: 900px // Fixed width; add media query for mobile later
padding: space('lg') // Generous padding
border-radius: 6px
border: 1px solid
div.error
background-color: rgba(color('accent-error'), 0.1) // Subtle red tint
color: color('accent-error') // Darker red text
border-color: color('accent-error')
div.notice
background-color: rgba(color('accent-success'), 0.1) // Green tint
color: color('accent-success') // Heavier green text
border-color: color('accent-success')
div#debug
background-color: color('bg-darker') // Deeper bg for debug
color: $text-muted // Muted green
border-color: color('border-light')
margin-top: space('2xl')
margin-bottom: space('2xl')
// Tables: Simplified; reset handles collapse/padding
.pages-table
width: 100%
border: 1px solid color('border-light') // Green border
th, td
border: 1px solid color('border-light')
text-align: left
th
background-color: rgba($accent-light, 0.05) // Subtle accent bg
color: $text-lighter // Header green
font-weight: 600 // Semi-bold; no bold in monospace

10
sass/framework/index.sass Normal file
View File

@@ -0,0 +1,10 @@
@forward 'main';
@forward 'ui';
@forward 'forms';
@forward 'login_form';
@forward 'logo';
@forward 'tabs';
@forward 'edit_page';
@forward 'tooltip';
@forward 'ace';
@forward 'tags';

6
sass/novaconium.sass Normal file
View File

@@ -0,0 +1,6 @@
// novaconium.sass
@use 'abstracts' as *
@use 'base' as *
@use 'framework' as *
@use 'controlPanel' as *
@use 'coming-soon' as *

View File

@@ -1,12 +1,13 @@
# Sample Docker Compose # Sample Docker Compose
services: services:
corxn: corxn:
image: 4lights/corxn:6.0.0 image: 4lights/corxn:8.5.3
ports: ports:
- "8000:80" - "8000:80"
volumes: volumes:
- ./novaconium:/data - ./novaconium:/data
- ./data/logs:/var/log/apache2 # Optional Logs - ./data/logs:/var/log/apache2 # Optional Logs
- "./logs:/data/logs"
restart: unless-stopped restart: unless-stopped
networks: networks:
- internal - internal
@@ -20,7 +21,6 @@ services:
mariadb: mariadb:
image: mariadb:latest image: mariadb:latest
container_name: mariadb
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: novadb MYSQL_DATABASE: novadb
@@ -45,9 +45,6 @@ services:
- PMA_USER=root - PMA_USER=root
- PMA_PASSWORD=${MYSQL_ROOT_PASSWORD} - PMA_PASSWORD=${MYSQL_ROOT_PASSWORD}
- UPLOAD_LIMIT=200M - UPLOAD_LIMIT=200M
volumes:
- "/etc/timezone:/etc/timezone:ro"
- "/etc/localtime:/etc/localtime:ro"
networks: networks:
proxy: proxy:

View File

@@ -10,5 +10,8 @@ $config = [
'base_url' => 'http://localhost:8000', 'base_url' => 'http://localhost:8000',
'secure_key' => '', //64 alphanumeric characters 'secure_key' => '', //64 alphanumeric characters
'logfile' => '/logs/novaconium.log', 'logfile' => '/logs/novaconium.log',
'loglevel' => 'ERROR' // 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'NONE' 'loglevel' => 'ERROR', // 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'NONE',
'matomo_url' => 'matomo.4lt.ca',
'matomo_id' => '0',
'fonts' => 'https://fonts.googleapis.com/css2?family=VT323:wght@400&family=Fira+Code:wght@400;500&display=swap&family=Material+Icons:wght@400;500&display=swap'
]; ];

View File

@@ -1,15 +0,0 @@
<?php
// Define our status code and message
$status_code = 404;
$status_message = 'The requested resource could not be found.';
// Set the HTTP response code and message
http_response_code($status_code);
header("Content-Type: text/html");
?>
<h1>Error 404 Resource Not found</h1>
<p><?php echo $status_message; ?></p>
<p style="font-size:10px; margin-top:60px">Novaconium Default 404 page.</p>

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=UTF-8');
header('Cache-Control: public, max-age=604800');
echo <<<TXT
/*
* This humans.txt was generated by the framework.
* You may edit or remove it without affecting your site.
*/
/* FRAMEWORK */
Built With: Novaconium PHP
repo: https://git.4lt.ca/4lt/novaconium
Human: Nick Yeoman
url: https://www.nickyeoman.com/
Occupation: Linux Systems Administrator / Software Framework Author
Location: Canada
/* SITE */
Built with: Novaconium PHP
Runs on: CORXN Container
Optimized for: Humans
/* META */
There are four lights.
TXT;

View File

@@ -1,2 +1,7 @@
<?php <?php
$data = array_merge($data, [
'title' => 'Welcome to Novaconium Index Page',
'pageclass' => 'novaconium'
]);
view('index'); view('index');

View File

@@ -0,0 +1,33 @@
<?php
$slug = $router->parameters['slug'];
$query=<<<EOSQL
SELECT
id,
title,
heading,
description,
keywords,
author,
body,
created,
updated
FROM pages
WHERE slug = '$slug'
EOSQL;
//$db->debugGetRow($query);
$data = $db->getRow($query);
if(!$data) {
http_response_code('404');
header("Content-Type: text/html");
view('404');
exit;
}
$data = array_merge($data, [
'menuActive' => 'blog',
'pageid' => 'page',
'permissionsGroup' => $session->get('group')
]);
view('page', $data);

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
// Send proper headers
header('Content-Type: text/plain; charset=UTF-8');
header('Cache-Control: public, max-age=604800');
// Use $config['base_url'] as-is
$baseUrl = $config['base_url'];
echo <<<TXT
# robots.txt for sites powered by Novaconium framework
User-agent: *
Disallow: /novaconium/
Sitemap: {$baseUrl}/sitemap.xml
TXT;

View File

@@ -2,5 +2,14 @@
$routes = [ $routes = [
'/' => [ '/' => [
'get' => 'index' 'get' => 'index'
],
'/robots.txt' => [
'get' => 'robots'
],
'/humans.txt' => [
'get' => 'humans'
],
'/page/{slug}' => [
'get' => 'page'
] ]
]; ];

View File

@@ -1,7 +1,7 @@
{% extends '@novaconium/master.html.twig' %} {% extends '@novaconium/master.html.twig' %}
{% block content %} {% block content %}
<img src="https://git.4lt.ca/4lt/novaconium/media/branch/master/_assets/novaconium-logo.png" aalt="Novaconium framework logo" /> <h1 id="biglogo"><span class="main">Novaconium PHP</span></h1>
<h2>Minimalist PHP framework</h2> <h2>Minimalist PHP framework</h2>
<p> <p>
Edit <code>App/routes.php</code> and <code>App/controllers/index.php</code><br> Edit <code>App/routes.php</code> and <code>App/controllers/index.php</code><br>

View File

@@ -0,0 +1,11 @@
{% extends '@novaconium/master.html.twig' %}
{% block content %}
<h1>{{ title }}</h1>
<div class="page-meta">
{% if updated is not empty %}
<span class="page-updated">Last Updated: {{ updated | date("M\\. jS Y \\a\\t g:ia") }}</span>
{% endif %}
</div>
{{ body | raw}}
{% endblock %}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,19 @@
<?php <?php
// Enable error reporting for development environments
// error_reporting(E_ALL); // error_reporting(E_ALL);
// ini_set('display_errors', 1); // ini_set('display_errors', 1);
define('BASEPATH', dirname(__DIR__, 1));
require_once(BASEPATH . '/vendor/4lt/novaconium/src/novaconium.php'); // Define the base path where the website is running from
?> define('BASEPATH', dirname(__DIR__));
// Load Composer's autoload file to handle class autoloading
require BASEPATH . '/vendor/autoload.php';
// Define the framework path
define('FRAMEWORKPATH', BASEPATH . '/vendor/4lt/novaconium');
// Bootstrap the Novaconium framework, which will create necessary objects like $session, $db, etc.
require FRAMEWORKPATH . '/src/novaconium.php';
// Run the application
makeitso();

View File

@@ -0,0 +1,185 @@
// /js/tabs.js
// Tab switching
function switchTab(tabId, button) {
const contents = document.querySelectorAll('.tab-content');
contents.forEach(content => content.classList.remove('active'));
const buttons = document.querySelectorAll('.tab-button');
buttons.forEach(b => b.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
button.classList.add('active');
if (tabId === 'content6') {
setTimeout(initTags, 0);
}
}
// Tags Init (with custom dropdown autocomplete)
let tagsListeners = [];
function initTags() {
const tagsInput = document.getElementById('tags-input');
const tagsField = document.getElementById('tags');
const hiddenTags = document.getElementById('tags_json');
const dropdown = document.getElementById('tags-dropdown');
if (!tagsInput || !tagsField || !hiddenTags || !dropdown) {
console.warn('Tags elements missing');
return;
}
// Remove old listeners
tagsListeners.forEach(ls => ls());
tagsListeners = [];
let tags = [];
let existingTags = [];
try {
tags = JSON.parse(tagsInput.dataset.tags || '[]');
existingTags = JSON.parse(tagsInput.dataset.existingTags || '[]');
} catch (e) {
console.warn('JSON error:', e);
}
let selectedIndex = -1;
function renderTags() {
tagsInput.innerHTML = '';
tags.forEach((tag, index) => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `${tag} <button type="button" class="tag-remove">&times;</button>`;
chip.querySelector('.tag-remove').onclick = () => {
tags.splice(index, 1);
renderTags();
hiddenTags.value = JSON.stringify(tags);
};
tagsInput.appendChild(chip);
});
tagsInput.appendChild(tagsField);
tagsInput.appendChild(dropdown);
hiddenTags.value = JSON.stringify(tags);
}
function updateDropdown() {
const value = tagsField.value.toLowerCase().trim();
dropdown.innerHTML = '';
dropdown.setAttribute('aria-expanded', value ? 'true' : 'false');
selectedIndex = -1;
if (!value) return;
const matches = existingTags
.filter(tag => tag.toLowerCase().startsWith(value) && !tags.includes(tag.toLowerCase()))
.slice(0, 10);
matches.forEach((tag, index) => {
const li = document.createElement('li');
li.textContent = tag;
li.setAttribute('role', 'option');
li.onclick = () => selectTag(tag);
li.onmouseover = () => { selectedIndex = index; updateHighlight(); };
dropdown.appendChild(li);
});
if (matches.length) dropdown.parentElement.classList.add('has-dropdown');
else dropdown.parentElement.classList.remove('has-dropdown');
}
function updateHighlight() {
dropdown.querySelectorAll('li').forEach((li, index) => {
li.classList.toggle('selected', index === selectedIndex);
});
}
function selectTag(tag) {
addTag(tag, true);
tagsField.value = '';
dropdown.setAttribute('aria-expanded', 'false');
}
function addTag(inputValue, refocus = false) {
const tag = inputValue.trim().toLowerCase().replace(/[^\w-]/g, '');
if (tag && tag.length > 0 && tag.length <= 50 && !tags.includes(tag)) {
tags.push(tag);
renderTags();
if (refocus) tagsField.focus();
}
tagsField.value = '';
updateDropdown();
}
const keydownListener = (e) => {
if (dropdown.getAttribute('aria-expanded') === 'true') {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, dropdown.querySelectorAll('li').length - 1);
updateHighlight();
dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`)?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
updateHighlight();
dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`)?.scrollIntoView({ block: 'nearest' });
} else if ((e.key === 'Enter' || e.key === 'Tab') && selectedIndex >= 0) {
e.preventDefault();
const selected = dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`)?.textContent;
if (selected) selectTag(selected);
} else if (e.key === 'Escape') {
e.preventDefault();
tagsField.blur();
}
}
if (!dropdown.getAttribute('aria-expanded') === 'true' || selectedIndex < 0) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(tagsField.value, true);
} else if (e.key === 'Tab') {
e.preventDefault();
if (tagsField.value.trim()) addTag(tagsField.value, true);
}
}
};
tagsField.addEventListener('keydown', keydownListener);
tagsListeners.push(() => tagsField.removeEventListener('keydown', keydownListener));
const inputListener = () => updateDropdown();
tagsField.addEventListener('input', inputListener);
tagsListeners.push(() => tagsField.removeEventListener('input', inputListener));
const blurListener = () => {
setTimeout(() => dropdown.setAttribute('aria-expanded', 'false'), 150);
if (tagsField.value.trim()) addTag(tagsField.value);
};
tagsField.addEventListener('blur', blurListener);
tagsListeners.push(() => tagsField.removeEventListener('blur', blurListener));
renderTags();
}
// Init on load and observe tab changes
document.addEventListener('DOMContentLoaded', () => {
const tagsTab = document.getElementById('content6');
if (tagsTab?.classList.contains('active')) {
initTags();
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target;
if (target.id === 'content6' && target.classList.contains('active')) {
initTags();
}
}
});
});
observer.observe(document.body, { subtree: true, attributes: true });
});
// Export switchTab for external use if needed
window.switchTab = switchTab;

View File

@@ -0,0 +1 @@
@use '../vendor/4lt/novaconium/sass/novaconium'

View File

@@ -1,4 +1,5 @@
<?php <?php
namespace Novaconium;
class Database { class Database {
@@ -6,7 +7,7 @@ class Database {
public $lastid; public $lastid;
public function __construct($dbinfo) { public function __construct($dbinfo) {
$this->conn = new mysqli($dbinfo['host'], $dbinfo['user'], $dbinfo['pass'], $dbinfo['name']); $this->conn = new \mysqli($dbinfo['host'], $dbinfo['user'], $dbinfo['pass'], $dbinfo['name']);
if ($this->conn->connect_error) { if ($this->conn->connect_error) {
die("Connection failed: " . $this->conn->connect_error); die("Connection failed: " . $this->conn->connect_error);
} }

View File

@@ -1,4 +1,5 @@
<?php <?php
namespace Novaconium;
class Logger { class Logger {
protected string $logFile; protected string $logFile;
protected int $logLevelThreshold; protected int $logLevelThreshold;
@@ -42,6 +43,11 @@ class Logger {
$this->log('WARNING', $msg); $this->log('WARNING', $msg);
} }
// Alias
public function warn(string $msg): void {
$this->log('WARNING', $msg);
}
public function error(string $msg): void { public function error(string $msg): void {
$this->log('ERROR', $msg); $this->log('ERROR', $msg);
} }

View File

@@ -1,5 +1,5 @@
<?php <?php
namespace Novaconium;
class MessageHandler { class MessageHandler {
private $messages = [ private $messages = [
'error' => [], 'error' => [],

View File

@@ -1,5 +1,5 @@
<?php <?php
namespace Novaconium;
class Post { class Post {
private $data = []; private $data = [];

View File

@@ -1,5 +1,5 @@
<?php <?php
namespace Novaconium;
/** /**
* Use * Use
* $redirect->url('/login'); * $redirect->url('/login');

View File

@@ -1,4 +1,5 @@
<?php <?php
namespace Novaconium;
class Router { class Router {
public $routes = []; public $routes = [];
@@ -10,120 +11,185 @@ class Router {
public $requestType = 'get'; public $requestType = 'get';
public function __construct() { public function __construct() {
$this->routes = $this->loadRoutes(); $this->routes = $this->loadRoutes();
$this->path = $this->preparePath(); $this->path = $this->preparePath();
$this->query = $this->prepareQuery(); $this->query = $this->prepareQuery();
$this->requestType = $this->getRequestType(); $this->requestType = $this->getRequestType();
$this->controller = $this->findController(); $this->controller = $this->findController();
$this->controllerPath = $this->setRouteFile(); $this->controllerPath = $this->setRouteFile();
} }
/**
* Merge framework routes with app-defined routes (app routes can override framework routes).
*/
private function loadRoutes() { private function loadRoutes() {
require_once(FRAMEWORKPATH . '/config/routes.php'); require_once(\FRAMEWORKPATH . '/config/routes.php');
// Check if Path exists
if (file_exists(BASEPATH . '/App/routes.php')) {
require_once( BASEPATH . '/App/routes.php');
}
$routes = array_merge((array)$routes, (array)$framework_routes);
return $routes; if (file_exists(\BASEPATH . '/App/routes.php')) {
require_once(\BASEPATH . '/App/routes.php');
}
return array_merge((array)$routes, (array)$framework_routes);
} }
/**
* Normalize the request path: strip query string, trailing slash, and anything after "&".
*/
private function preparePath() { private function preparePath() {
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
//homepage
if ($path === '/') { if ($path === '/') {
return $path; return $path;
} }
// remove empty directory path $path = rtrim($path, '/');
$path = rtrim($path, '/'); // remove trailing slash
//remove anything after and including ampersand
$path = preg_replace('/&.+$/', '', $path); $path = preg_replace('/&.+$/', '', $path);
return $path; return $path;
} }
private function prepareQuery() { private function prepareQuery() {
$parsedUri = parse_url($_SERVER['REQUEST_URI']); $parsedUri = parse_url($_SERVER['REQUEST_URI']);
$queryArray = []; $queryArray = [];
if (isset($parsedUri['query'])) { if (isset($parsedUri['query'])) {
parse_str($parsedUri['query'], $queryArray); parse_str($parsedUri['query'], $queryArray);
} }
return $queryArray; return $queryArray;
} }
private function getRequestType() { private function getRequestType() {
// is the requewst a get or post? return empty($_POST) ? 'get' : 'post';
if (empty($_POST)) {
return 'get';
} else {
return 'post';
}
} }
/**
* Resolve the current path to a controller string.
* First tries an exact match, then falls back to matching parameterized
* routes like "/users/{id}".
*/
private function findController() { private function findController() {
$exactMatch = $this->findExactMatch();
// one to one match if ($exactMatch !== null) {
if (array_key_exists($this->path, $this->routes)) { return $exactMatch;
if (!empty($this->routes[$this->path][$this->requestType])) {
return $this->routes[$this->path][$this->requestType];
}
} }
foreach ($this->routes as $key => $value) { $paramMatch = $this->findParameterizedMatch();
// Check if key contains a curly bracket, if not continue. We already checked above. if ($paramMatch !== null) {
if (!strpos($key, '{')) continue; return $paramMatch;
// Remove everything after the curly bracket, from key
$keyPath = substr($key, 0, strpos($key, '{'));
//see if keyPath matches the first characters of $this->path, only the first characters have to match
if (strpos($this->path, $keyPath) === 0) {
// We have a potential match. Now check if the parameter count is equal
$keyParams = explode('/', $key);
$pathParams = explode('/', $this->path);
$keyParamCount = count($keyParams);
$pathParamCount = count($pathParams);
if ($keyParamCount === $pathParamCount) {
for ($i=0; $i < $pathParamCount; $i++) {
if (strpos($keyParams[$i], '{') !== false) {
$keyParams[$i] = substr($keyParams[$i], 1, -1);
$this->parameters[$keyParams[$i]] = $pathParams[$i];
return $this->routes[$key][$this->requestType];
}
}
}
}
} }
return '404'; return '404';
} }
// checks if the file exists, sets file path private function findExactMatch() {
if (array_key_exists($this->path, $this->routes)
&& !empty($this->routes[$this->path][$this->requestType])
) {
return $this->routes[$this->path][$this->requestType];
}
return null;
}
/**
* Loop over parameterized routes (those containing "{") looking for the
* first one whose static prefix matches the path AND whose segment count
* matches. As soon as such a route is found, control is handed off to
* extractControllerFromMatch(), which returns immediately (even with a
* `null` controller) -- this mirrors the original code's behavior of
* stopping the search on the first prefix+segment-count match, rather
* than continuing on to try other candidate routes.
*/
private function findParameterizedMatch() {
foreach ($this->routes as $routePattern => $methods) {
// Only parameterized routes (containing "{") are relevant here;
// plain routes were already checked in findExactMatch().
if (strpos($routePattern, '{') === false) {
continue;
}
if (!$this->routeMatchesPath($routePattern)) {
continue;
}
if (!$this->segmentCountsMatch($routePattern)) {
continue;
}
// Original behavior: once we find a route with a matching prefix
// and segment count, we commit to it -- even if it turns out
// there's no handler for the current request method.
return $this->extractControllerFromMatch($routePattern, $methods);
}
return null;
}
/**
* Quick check: does the static portion of the route pattern (everything
* before the first "{") prefix-match the current path?
*/
private function routeMatchesPath($routePattern) {
$staticPrefix = substr($routePattern, 0, strpos($routePattern, '{'));
return strpos($this->path, $staticPrefix) === 0;
}
private function segmentCountsMatch($routePattern) {
$patternSegments = explode('/', $routePattern);
$pathSegments = explode('/', $this->path);
return count($patternSegments) === count($pathSegments);
}
/**
* Extracts named parameters into $this->parameters and returns the
* controller for the current request type (or null if that method
* isn't defined for this route).
*
* Matches the original quirk: only the FIRST "{param}" segment found
* gets added to $this->parameters, and the method returns right away --
* any additional "{param}" segments later in the pattern are never
* processed. This is preserved as-is rather than "fixed", since
* behavior should stay unchanged.
*/
private function extractControllerFromMatch($routePattern, $methods) {
$patternSegments = explode('/', $routePattern);
$pathSegments = explode('/', $this->path);
foreach ($patternSegments as $i => $segment) {
if (strpos($segment, '{') !== false) {
$paramName = substr($segment, 1, -1); // strip { }
$this->parameters[$paramName] = $pathSegments[$i];
return $methods[$this->requestType] ?? null;
}
}
return null;
}
/**
* Resolve the controller string to an actual file path, falling back to a 404 controller.
*/
private function setRouteFile() { private function setRouteFile() {
if (str_starts_with($this->controller, 'NOVACONIUM')) { if (str_starts_with($this->controller, 'NOVACONIUM')) {
$trimmed = substr($this->controller, strlen('NOVACONIUM/')); $trimmed = substr($this->controller, strlen('NOVACONIUM/'));
$cp = FRAMEWORKPATH . '/controllers/' . $trimmed . '.php'; $controllerPath = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php';
} else { } else {
$cp = BASEPATH . '/App/controllers/' . $this->controller . '.php'; $controllerPath = \BASEPATH . '/App/controllers/' . $this->controller . '.php';
} }
if (file_exists($cp)) { if (file_exists($controllerPath)) {
return $cp; return $controllerPath;
} else {
//Check if 404 exits
if (file_exists(BASEPATH . '/App/controllers/404.php')) {
return BASEPATH . '/App/controllers/404.php';
} else {
return FRAMEWORKPATH . '/defaults/App/controllers/404.php';
}
} }
if (file_exists(\BASEPATH . '/App/controllers/404.php')) {
return \BASEPATH . '/App/controllers/404.php';
}
return \FRAMEWORKPATH . '/controllers/404.php';
} }
public function debug() { public function debug() {
@@ -134,9 +200,7 @@ class Router {
echo '<tr><th>Parameters</th><td><pre>' . print_r($this->parameters, true) . '</pre></td></tr>'; echo '<tr><th>Parameters</th><td><pre>' . print_r($this->parameters, true) . '</pre></td></tr>';
echo '<tr><th>Routes</th><td><pre>' . print_r($this->routes, true) . '</pre></td></tr>'; echo '<tr><th>Routes</th><td><pre>' . print_r($this->routes, true) . '</pre></td></tr>';
echo '</table></div>'; echo '</table></div>';
die(); die();
} }
}
}

View File

@@ -1,4 +1,5 @@
<?php <?php
namespace Novaconium\Services;
class Auth class Auth
{ {

View File

@@ -0,0 +1,53 @@
<?php
namespace Novaconium\Services;
class TagManager
{
protected $db;
public function __construct()
{
global $db;
$this->db = $db;
}
/**
* Assign tags to a page.
*
* This will delete old links and insert new ones.
*
* @param int $pageId
* @param array $tags Array of tag names
*/
public function setTagsForPage(int $pageId, array $tags): void
{
// Remove existing links
$this->db->query("DELETE FROM page_tags WHERE page_id = ?", [$pageId]);
foreach ($tags as $tagName) {
$tagName = trim($tagName);
if ($tagName === '') continue;
// Check if tag exists
$stmt = $this->db->query("SELECT id FROM tags WHERE name = ?", [$tagName]);
$row = $stmt->fetch_assoc(); // mysqli_result -> assoc array
if ($row) {
$tagId = $row['id'];
} else {
// Insert new tag
$this->db->query(
"INSERT INTO tags (name, created) VALUES (?, NOW())",
[$tagName]
);
$tagId = $this->db->lastid;
}
// Link page to tag
$this->db->query(
"INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)",
[$pageId, $tagId]
);
}
}
}

View File

@@ -1,23 +1,29 @@
<?php <?php
namespace Novaconium;
class Session { class Session {
private $session; private $session;
public function __construct() { public function __construct() {
session_start(); if (session_status() === PHP_SESSION_NONE) {
if (!isset($_SESSION['token'])) { session_start();
$this->setToken(); }
$this->session['messages'] = []; $this->session = &$_SESSION; // Reference $_SESSION to keep them in sync
} else { if (!isset($this->session['token'])) {
$this->session = $_SESSION; $this->setToken();
}
if (!isset($this->session['messages'])) {
$this->session['messages'] = []; // Always ensure messages is an array
}
if (!isset($this->session['formData'])) {
$this->session['formData'] = []; // Initialize formData
}
if (!isset($this->session['errors'])) {
$this->session['errors'] = []; // Initialize errors
} }
} }
public function setToken() { public function setToken() {
if (!isset($this->session['token'])) { $this->session['token'] = bin2hex(random_bytes(32));
$this->session['token'] = bin2hex(random_bytes(32));
}
} }
public function set($key, $value) { public function set($key, $value) {
@@ -45,13 +51,13 @@ class Session {
} }
public function write() { public function write() {
$_SESSION = $this->session; // No need to assign to $_SESSION since $this->session is a reference
session_write_close(); session_write_close();
} }
public function kill() { public function kill() {
$this->session = [];
$_SESSION = []; $_SESSION = [];
session_destroy(); session_destroy();
} }
} }

View File

@@ -1,29 +1,60 @@
<?php <?php
declare(strict_types=1);
/** /**
* Dump and Die * Dump and Die (debug function)
*
* @param mixed ...$vars Any number of variables to dump and then exit the script.
*/ */
function dd(...$vars) { function dd(...$vars): void {
echo "<pre style='background:#222;color:#0f0;padding:10px;border-radius:5px;'>"; echo "<pre style='background:#1e1e1e;color:#00ff00;padding:12px;border-radius:6px;font-size:14px;'>";
foreach ($vars as $var) { foreach ($vars as $var) {
var_dump($var); var_dump($var);
echo "\n"; echo "\n";
} }
echo "</pre>"; echo "</pre>";
die(); exit;
} }
function makeitso() { /**
* Finalize the request lifecycle.
*
* This function safely writes session data, stores flash messages, closes the database
* connection if configured, and performs a redirect.
*/
function makeitso(): void {
global $session, $db, $redirect, $config, $messages, $log; global $session, $db, $redirect, $config, $messages, $log;
if (!empty($config['database']['host'])) { // ------------------------------
$db->close(); // Close database if configured
// ------------------------------
// Check if database configuration exists and the $db object is set
if (!empty($config['database']['host']) && isset($db)) {
// If the $db object has a close method, call it to close the connection
if (method_exists($db, 'close')) {
$db->close();
}
} }
// ------------------------------
// Save flash messages to session
// ------------------------------
// Set all messages in the session
$session->set('messages', $messages->getAllMessages()); $session->set('messages', $messages->getAllMessages());
// Write any buffered session data to persistent storage
$session->write(); $session->write();
// ------------------------------
// Perform redirect
// ------------------------------
// Execute the redirect operation
$redirect->execute(); $redirect->execute();
exit(); // Exit the script after processing is complete
} exit;
}

View File

@@ -1,63 +1,59 @@
<?php <?php
define('FRAMEWORKPATH', BASEPATH . '/vendor/4lt/novaconium');
require_once(BASEPATH . '/vendor/autoload.php'); use Novaconium\Logger;
use Novaconium\Session;
use Novaconium\MessageHandler;
use Novaconium\Database;
use Novaconium\Post;
use Novaconium\Redirect;
use Novaconium\Router;
//Check if config file exists $db = null;
if (file_exists(BASEPATH . '/App/config.php')) { $post = null;
require_once(BASEPATH . '/App/config.php');
// --- Load Config ---
if (file_exists(\BASEPATH . '/App/config.php')) {
require_once \BASEPATH . '/App/config.php';
} else { } else {
require_once(FRAMEWORKPATH . '/defaults/App/config.php'); require_once \FRAMEWORKPATH . '/skeleton/novaconium/App/config.php';
} }
// Logging require_once \FRAMEWORKPATH . '/src/functions.php';
require_once(FRAMEWORKPATH . '/src/Logger.php'); require_once \FRAMEWORKPATH . '/src/twig.php';
$log = new Logger(BASEPATH . $config['logfile'], $config['loglevel']);
// Global Functions // --- Logging ---
require_once(FRAMEWORKPATH . '/src/functions.php'); $log = new Logger(\BASEPATH . $config['logfile'], $config['loglevel']);
// Creates the view() function using twig // --- Twig Data Array ---
$data = array(); $data = [];
require_once(FRAMEWORKPATH . '/src/twig.php'); $data['fonts'] = $config['fonts'] ?? [];
$data['matomo_url'] = $config['matomo_url'] ?? '';
$data['matomo_id'] = $config['matomo_id'] ?? '0';
// Start a Session // --- Session ---
require_once(FRAMEWORKPATH . '/src/Session.php');
$session = new Session(); $session = new Session();
$data['token'] = $session->get('token'); $data['token'] = $session->get('token');
$data['username'] = $session->get('username'); $data['username'] = $session->get('username');
if ($config['loglevel'] == 'DEBUG') {
$data['debug'] = nl2br(print_r($session->debug(), true));
}
// Messages // --- Messages ---
require_once(FRAMEWORKPATH . '/src/MessageHandler.php');
$messages = new MessageHandler($session->flash('messages')); $messages = new MessageHandler($session->flash('messages'));
foreach (['error', 'notice'] as $key) {
foreach (['error','notice'] as $key){ $data[$key] = $messages->showMessages($key);
$data[$key] = $messages->showMessages($key);
} }
// Load Database Class // --- Database ---
if (!empty($config['database']['host'])) { if (!empty($config['database']['host'])) {
require_once(FRAMEWORKPATH . '/src/Database.php');
$db = new Database($config['database']); $db = new Database($config['database']);
} }
// Sanatize POST Data // --- POST Wrapper ---
if (!empty($_POST)) { if (!empty($_POST)) {
require_once(FRAMEWORKPATH . '/src/Post.php'); $post = new Post($_POST);
$post = new POST($_POST);
} }
// Start a Redirect // --- Redirect Handler ---
require_once(FRAMEWORKPATH . '/src/Redirect.php');
$redirect = new Redirect(); $redirect = new Redirect();
// Load a controller // --- Router ---
require_once(FRAMEWORKPATH . '/src/Router.php');
$router = new Router(); $router = new Router();
//$router->debug(); require_once $router->controllerPath;
require_once($router->controllerPath);
makeitso();

View File

@@ -1,32 +1,69 @@
<?php <?php
//Twig declare(strict_types=1);
function view($name = '', $moreData = []) {
global $config, $data; // Use the globally included $config
if (!empty($moreData)){ use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* Render a Twig view.
*
* @param string $name Template name without extension (e.g. "index")
* @param array $moreData Additional variables to merge into template context
*
* @return bool
*/
function view(string $name = '', array $moreData = []): bool
{
global $config, $data;
if (!is_array($data)) {
$data = [];
}
if (!empty($moreData)) {
$data = array_merge($data, $moreData); $data = array_merge($data, $moreData);
} }
$loader = new Twig\Loader\FilesystemLoader(BASEPATH . '/App/views/'); // ----------------------------------------
$loader->addPath(FRAMEWORKPATH . '/twig', 'novaconium'); // Setup Twig
$loader->addPath(FRAMEWORKPATH . '/views', 'novacore'); // ----------------------------------------
$loader->addPath(BASEPATH . '/App/templates', 'override');
$twig = new Twig\Environment($loader); $loader = new FilesystemLoader(BASEPATH . '/App/views/');
// Add namespace paths
if (is_dir(FRAMEWORKPATH . '/twig')) {
$loader->addPath(FRAMEWORKPATH . '/twig', 'novaconium');
}
if (is_dir(FRAMEWORKPATH . '/views')) {
$loader->addPath(FRAMEWORKPATH . '/views', 'novacore');
}
if (is_dir(BASEPATH . '/App/templates')) {
$loader->addPath(BASEPATH . '/App/templates', 'override');
}
$twig = new Environment($loader);
// Add config to Twig globally
$twig->addGlobal('config', $config); $twig->addGlobal('config', $config);
// Check if the template exists // ----------------------------------------
if (file_exists(BASEPATH . '/App/views/' . $name . '.html.twig')) { // Render template
// ----------------------------------------
$appTemplatePath = BASEPATH . '/App/views/' . $name . '.html.twig';
if (file_exists($appTemplatePath)) {
echo $twig->render($name . '.html.twig', $data); echo $twig->render($name . '.html.twig', $data);
return true; return true;
} elseif (str_starts_with($name, '@')) { // Check if using framework
echo $twig->render($name . '.html.twig', $data);
return true;
} else {
echo "Error: Twig Template ($name) Not Found.";
return false;
} }
}
if (str_starts_with($name, '@')) {
echo $twig->render($name . '.html.twig', $data);
return true;
}
echo "Error: Twig Template ($name) Not Found.";
return false;
}

View File

@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title | default('Coming Soon') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/novaconium.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
background: url("{{ bg_image | default('https://w.wallhaven.cc/full/5y/wallhaven-5yymk9.jpg') }}") no-repeat center center fixed;
background-size: cover;
}
</style>
</head>
<body id="coming-soon">
<div class="container">
<h1>{{ heading | default('Coming Soon') }}</h1>
{% block content %}{% endblock %}
{% if countdown %}
<div class="countdown" id="countdown"></div>
{% endif %}
{# TODO: listmonk #}
<form class="newsletter" action="#" method="post">
<label for="email">Subscribe to our newsletter:</label>
<input type="email" id="email" name="email" placeholder="Your email address" required>
<button type="submit">Subscribe</button>
</form>
{# Social media icons using Font Awesome #}
<div class="social-icons">
{% block social %}{% endblock %}
</div>
</div>
{% if countdown %}
<script>
{% if countdown %}
{% set default_launch = "now"|date_modify("+30 days")|date("Y-m-d\TH:i:s") %}
const launchDate = new Date('{{ launch_date | default(default_launch) }}').getTime();
const countdownEl = document.getElementById('countdown');
function updateCountdown() {
const now = new Date().getTime();
const distance = launchDate - now;
if (distance <= 0) {
countdownEl.innerText = 'Launched!';
return;
}
const days = Math.floor(distance / (1000*60*60*24));
const hours = Math.floor((distance % (1000*60*60*24)) / (1000*60*60));
const mins = Math.floor((distance % (1000*60*60)) / (1000*60));
const secs = Math.floor((distance % (1000*60)) / 1000);
countdownEl.innerText = `${days}d ${hours}h ${mins}m ${secs}s`;
}
updateCountdown();
setInterval(updateCountdown, 1000);
{% endif %}
</script>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,44 @@
<!doctype html>
<html class="no-js" lang="en">
<head>
{% include ['@novaconium/cp/head.html.twig'] %}
</head>
<body id="{{ pageid | default('pageid') }}" class="{{ pageclass | default('pageclass') }}" >
{# Page Header #}
<header>
<h1 id="biglogo"><span class="main">Novaconium</span></h1>
</header>
<!-- Main Content Of The Page -->
<div id="panel" class="container">
{% include ['@novaconium/cp/menu.html.twig'] %}
<main id="content">
{% block content %}{% endblock %}
</main>
</div>
{# Page Footer #}
<footer>
<div class="copyright">&copy; {{ 'now' | date('Y') }} Novaconium</div>
</footer>
{% if editor == 'ace' %}
{% include '@novaconium/javascript/page-edit.html.twig' %}
{% include '@novaconium/javascript/ace.html.twig' %}
{% endif %}
{% if debug is not empty %}
<div id="debug">
<h2>Debugging Information</h2>
{{ debug|raw }}
</div>
{% endif %}
</body></html>

60
twig/cp/head.html.twig Normal file
View File

@@ -0,0 +1,60 @@
{# =============================================================================
<HEAD>
=============================================================================
#}
<meta charset="utf-8">
<title>{{ title | default('Welcome To Novaconium') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{# SEO & METADATA #}
<meta name="generator" content="Novaconium" />
<meta name="description" content="{{ description | default('No description given') }}">
<meta name="keywords" content="{{ keywords | default('website') }}">
<meta name="author" content="{{ author | default('anonymous') }}">
{# DARK MODE & THEME HINTS #}
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#000000">
{# OPEN GRAPH (OG) FOR SOCIAL SHARING #}
<meta property="og:title" content="{{ title | default('Welcome') }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ app_url | default(request.scheme ~ '://' ~ request.host) }}">
<meta property="og:image" content="{{ og_image | default('/icon.png') }}">
{# PWA & FAVICONS #}
<link rel="manifest" href="site.webmanifest">
<link rel="apple-touch-icon" href="/icon.png">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
{# GOOGLE FONTS (CDN VIA PRECONNECT) #}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="{{ fonts | default('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=Roboto+Mono&family=Source+Code+Pro&family=Lato&family=Poppins&family=Material+Icons&family=Material+Icons+Outlined&family=VT323:wght@400&family=Fira+Code:wght@400;500&display=swap') }}"
rel="stylesheet"
>
{% if editor == 'ace' %}
<!-- ACE Editor -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/ace/1.35.2/ace.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.35.2/ace.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.35.2/mode-html.min.js"></script> {# HTML syntax #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.35.2/theme-tomorrow_night.min.js"></script> {# Dark theme #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.35.2/ext-language_tools.min.js"></script> {# Autocomplete #}
<!-- END ACE Editor -->
{% endif %}
{# highlight.js #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/ir-black.min.css">
<script src="/js/tabs.js"></script>
{# STYLESHEET #}
<link rel="stylesheet" href="/css/novaconium.css">

9
twig/cp/menu.html.twig Normal file
View File

@@ -0,0 +1,9 @@
<div id="cp-menu">
<ul id="cp-nav">
<li><a href="/novaconium/dashboard">Dashboard</a></li>
<li><a href="/novaconium/pages">Pages</a></li>
<li><a href="/novaconium/messages">Messages</a></li>
<li><a href="/novaconium/settings">Settings</a></li>
<li><a href="/novaconium/logout">Logout</a></li>
</ul>
</div>

View File

@@ -1,6 +1,5 @@
<!-- <!--
What goes very last on the page. What goes very last on the page.
right before the /body right before the /body
like javascript such as javascript
or analytics -->
-->

View File

@@ -1,3 +1 @@
<!-- <div class="copyright">&copy; {{ 'now' | date('Y') }} Novaconium</div>
What goes in the footer html tag
-->

View File

@@ -1,26 +1,48 @@
<meta charset="utf-8"> {# =============================================================================
<title>{{ title | default('Welcome To Novaconium') }}</title> <HEAD>
<meta name="generator" content="Novaconium" /> =============================================================================
#}
<meta charset="utf-8">
<title>{{ title | default('Welcome To Novaconium') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{# SEO & METADATA #}
<meta name="generator" content="Novaconium" />
<meta name="description" content="{{ description | default('No description given') }}"> <meta name="description" content="{{ description | default('No description given') }}">
<meta name="keywords" content="{{ keywords | default('website') }}"> <meta name="keywords" content="{{ keywords | default('website') }}">
<meta name="author" content="{{ author | default('anonymous') }}"> <meta name="author" content="{{ author | default('anonymous') }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# DARK MODE & THEME HINTS #}
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#000000">
{# OPEN GRAPH (OG) FOR SOCIAL SHARING #}
<meta property="og:title" content="{{ title | default('Welcome') }}"> <meta property="og:title" content="{{ title | default('Welcome') }}">
<meta property="og:type" content=""> <meta property="og:type" content="website">
<meta property="og:url" content=""> <meta property="og:url" content="{{ app_url | default(request.scheme ~ '://' ~ request.host) }}">
<meta property="og:image" content=""> <meta property="og:image" content="{{ og_image | default('/icon.png') }}">
{# PWA & FAVICONS #}
<link rel="manifest" href="site.webmanifest"> <link rel="manifest" href="site.webmanifest">
<link rel="apple-touch-icon" href="/icon.png"> <link rel="apple-touch-icon" href="/icon.png">
<!-- Place favicon.ico in the root directory -->
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
{# https://developers.google.com/fonts/docs/getting_started #} {# GOOGLE FONTS (CDN VIA PRECONNECT) #}
<link href="https://fonts.googleapis.com/css2?family=Fira+Code&family=Source+Code+Pro&display=swap&family=Material+Icons&family=Material+Icons+Outlined" rel="stylesheet"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="{{ fonts | default('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=Roboto+Mono&family=Source+Code+Pro&family=Lato&family=Poppins&family=Material+Icons&family=Material+Icons+Outlined&family=VT323:wght@400&family=Fira+Code:wght@400;500&display=swap') }}"
rel="stylesheet"
>
{# STYLESHEET #}
<link rel="stylesheet" href="/css/novaconium.css"> <link rel="stylesheet" href="/css/novaconium.css">
<meta name="theme-color" content="#000000"> {# highlight.js #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/ir-black.min.css">

View File

@@ -0,0 +1,40 @@
<!-- Ace Editor -->
<script>
// Ace Editor Init (cleaned: removed duplicate setUseWorker, added null checks)
document.addEventListener('DOMContentLoaded', function() {
const bodyTextarea = document.getElementById('body');
const bodyEditorEl = document.getElementById('body-editor');
if (bodyTextarea && bodyEditorEl && typeof ace !== 'undefined') { // Check ace loaded
try {
const editor = ace.edit(bodyEditorEl);
editor.session.setValue(bodyTextarea.value || ''); // Load initial HTML
editor.session.setMode('ace/mode/html'); // HTML syntax highlighting
editor.setTheme('ace/theme/tomorrow_night'); // Dark theme (black bg, green accents)
editor.setOptions({
fontSize: 14,
showPrintMargin: false,
wrap: true, // Line wrapping
useWorker: false // Disable worker for linting if not needed (faster)
});
editor.session.setUseWorker(false); // No JS linting in HTML mode
// Enable basic autocomplete
editor.setOptions({ enableBasicAutocompletion: true });
// Sync back to textarea on change
editor.session.on('change', function() {
bodyTextarea.value = editor.getValue(); // Full HTML string
});
console.log('Ace loaded! Initial value:', editor.getValue().substring(0, 50) + '...'); // Debug
} catch (error) {
console.error('Ace init failed:', error); // Graceful error
bodyEditorEl.style.display = 'none'; // Hide div, show plain textarea if needed
bodyTextarea.style.display = 'block';
}
} else {
console.warn('Ace elements or lib missing'); // Fallback to plain textarea
}
});
</script>

View File

@@ -0,0 +1,192 @@
<script>
// Tab switching JS (unchanged)
function switchTab(tabId, button) {
const contents = document.querySelectorAll('.tab-content');
contents.forEach(content => content.classList.remove('active'));
const buttons = document.querySelectorAll('.tab-button');
buttons.forEach(b => b.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
button.classList.add('active');
if (tabId === 'content6') {
setTimeout(initTags, 0);
}
}
// Tags Init (with custom dropdown autocomplete)
let tagsListeners = [];
function initTags() {
const tagsInput = document.getElementById('tags-input');
const tagsField = document.getElementById('tags');
const hiddenTags = document.getElementById('tags_json');
const dropdown = document.getElementById('tags-dropdown');
if (!tagsInput || !tagsField || !hiddenTags || !dropdown) {
console.warn('Tags elements missing');
return;
}
// Clean old listeners
tagsListeners.forEach(ls => ls());
tagsListeners = [];
let tags = [];
let existingTags = [];
try {
tags = JSON.parse(tagsInput.dataset.tags || '[]');
existingTags = JSON.parse(tagsInput.dataset.existingTags || '[]');
} catch (e) {
console.warn('JSON error:', e);
tags = [];
existingTags = [];
}
let selectedIndex = -1; // For keyboard nav
// Render chips (unchanged)
function renderTags() {
tagsInput.innerHTML = '';
tags.forEach((tag, index) => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `${tag} <button type="button" class="tag-remove">&times;</button>`;
chip.querySelector('.tag-remove').onclick = () => {
tags.splice(index, 1);
renderTags();
hiddenTags.value = JSON.stringify(tags);
};
tagsInput.appendChild(chip);
});
tagsInput.appendChild(tagsField);
tagsInput.appendChild(dropdown); // Re-add dropdown
hiddenTags.value = JSON.stringify(tags);
}
// Filter and render dropdown
function updateDropdown() {
const value = tagsField.value.toLowerCase().trim();
dropdown.innerHTML = '';
dropdown.setAttribute('aria-expanded', value ? 'true' : 'false');
selectedIndex = -1;
if (!value) return;
const matches = existingTags.filter(tag =>
tag.toLowerCase().startsWith(value) && !tags.includes(tag.toLowerCase())
).slice(0, 10); // Top 10 matches, exclude existing
matches.forEach((tag, index) => {
const li = document.createElement('li');
li.textContent = tag;
li.setAttribute('role', 'option');
li.onclick = () => selectTag(tag);
li.onmouseover = () => { selectedIndex = index; updateHighlight(); };
dropdown.appendChild(li);
});
if (matches.length) dropdown.parentElement.classList.add('has-dropdown');
else dropdown.parentElement.classList.remove('has-dropdown');
}
// Highlight selected in dropdown
function updateHighlight() {
dropdown.querySelectorAll('li').forEach((li, index) => {
li.classList.toggle('selected', index === selectedIndex);
});
}
// Select tag from dropdown
function selectTag(tag) {
addTag(tag, true); // Add as chip, refocus
tagsField.value = ''; // Clear input
dropdown.setAttribute('aria-expanded', 'false');
}
// Add tag (unchanged)
function addTag(inputValue, refocus = false) {
const tag = inputValue.trim().toLowerCase().replace(/[^\w-]/g, '');
if (tag && tag.length > 0 && tag.length <= 50 && !tags.includes(tag)) {
tags.push(tag);
renderTags();
if (refocus) tagsField.focus();
}
tagsField.value = '';
updateDropdown(); // Hide dropdown after add
}
// Events: Enter/comma/TAB adds tag (no form submit)
const keydownListener = (e) => {
console.log('Keydown:', e.key, 'Value:', tagsField.value, 'Selected:', selectedIndex);
if (dropdown.getAttribute('aria-expanded') === 'true') {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, dropdown.querySelectorAll('li').length - 1);
updateHighlight();
dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`).scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
updateHighlight();
dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`).scrollIntoView({ block: 'nearest' });
} else if ((e.key === 'Enter' || e.key === 'Tab') && selectedIndex >= 0) {
e.preventDefault();
const selected = dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`).textContent;
selectTag(selected); // Adds chip, refocuses
} else if (e.key === 'Escape') {
e.preventDefault();
tagsField.blur();
}
}
// Fallback for no dropdown or non-selected Tab/Enter
if (!dropdown.getAttribute('aria-expanded') === 'true' || selectedIndex < 0) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(tagsField.value, true);
} else if (e.key === 'Tab') {
e.preventDefault();
if (tagsField.value.trim()) addTag(tagsField.value, true); // Add typed value, refocus
}
}
};
tagsField.addEventListener('keydown', keydownListener);
tagsListeners.push(() => tagsField.removeEventListener('keydown', keydownListener));
const inputListener = () => updateDropdown();
tagsField.addEventListener('input', inputListener);
tagsListeners.push(() => tagsField.removeEventListener('input', inputListener));
const blurListener = () => {
setTimeout(() => dropdown.setAttribute('aria-expanded', 'false'), 150); // Hide after blur
if (tagsField.value.trim()) addTag(tagsField.value);
};
tagsField.addEventListener('blur', blurListener);
tagsListeners.push(() => tagsField.removeEventListener('blur', blurListener));
// Initial render
renderTags();
console.log('Tags ready with autocomplete, loaded:', tags.length, 'tags');
}
// Init on load + observe (unchanged)
document.addEventListener('DOMContentLoaded', function() {
const tagsTab = document.getElementById('content6');
if (tagsTab && tagsTab.classList.contains('active')) {
initTags();
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target;
if (target.id === 'content6' && target.classList.contains('active')) {
initTags();
}
}
});
});
observer.observe(document.body, { subtree: true, attributes: true });
});
</script>

View File

@@ -1,11 +1,16 @@
{% if username is not empty %}
<div class="left"> <div class="left">
<ul id="leftnav"> {% if pageclass == "novaconium" %}
<li><a href="/">Home</a></li> <ul id="leftnav">
<li><a href="/novaconium/dashboard">Dashboard</a></li> <li><a href="/">Home</a></li>
<li><a href="/novaconium/pages">Pages</a></li>
<li><a href="/novaconium/messages">Messages</a></li> {% if username is not empty %}
<li><a href="/novaconium/logout">Logout</a></li> <li><a href="/novaconium/dashboard">Dashboard</a></li>
</ul> <li><a href="/novaconium/pages">Pages</a></li>
<li><a href="/novaconium/messages">Messages</a></li>
<li><a href="/novaconium/logout">Logout</a></li>
{% else %}
<li><a href="/novaconium/login">Login</a></li>
{% endif %}
</ul>
{% endif %}
</div> </div>
{% endif %}

View File

@@ -60,4 +60,32 @@
{% endif %} {% endif %}
{% include ['@override/foot.html.twig', '@novaconium/foot.html.twig'] %} {% include ['@override/foot.html.twig', '@novaconium/foot.html.twig'] %}
{% if matomo_id > 0 %}
<!-- Matomo -->
<script>
var _paq = window._paq = window._paq || [];
{% if is_404 %}
_paq.push([
'setDocumentTitle',
'404 / Not Found - ' + document.location.pathname
]);
{% endif %}
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u = "//{{ matomo_url|trim('/') }}/";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', {{ matomo_id }}]);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true;
g.src = u + 'matomo.js';
s.parentNode.insertBefore(g, s);
})();
</script>
<!-- End Matomo Code -->
{% endif %}
</body></html> </body></html>

8
views/404.html.twig Normal file
View File

@@ -0,0 +1,8 @@
{% set is_404 = status_code is defined and status_code == 404 %}
{% extends '@novaconium/master.html.twig' %}
{% block content %}
<h1>404 File Not Found</h1>
<p style="font-size:10px; margin-top:60px">Novaconium Default 404 page.</p>
<p><a href="/">Return Home</a></p>
{% endblock %}

View File

@@ -4,7 +4,7 @@
<h1>{{title}}</h1> <h1>{{title}}</h1>
<div id="login_form"> <div id="login">
<form method="post" action="/novaconium/login"> <form method="post" action="/novaconium/login">
<input type="hidden" name="token" value="{{ token }}" /> <input type="hidden" name="token" value="{{ token }}" />

View File

@@ -0,0 +1,32 @@
{% extends '@novaconium/coming-soon/index.html.twig' %}
{% set bg_image = "https://i.4lt.ca/4lt/waterBubbles.webp" %}
{% set default_launch = "now"|date_modify("+30 days")|date("Y-m-d\TH:i:s") %}
{% block content %}
<p>Our website is under construction. Were working hard to bring you a better experience.</p>
<p>Stay tuned for updates!</p>
{% endblock %}
{% block social %}
<a href="mailto:you@example.com" title="Email">
<i class="fa-solid fa-envelope"></i>
</a>
<a href="tel:+1234567890" title="Phone">
<i class="fa-solid fa-phone"></i>
</a>
<a href="#" title="Twitter">
<i class="fab fa-twitter"></i>
</a>
<a href="#" title="Facebook">
<i class="fab fa-facebook-f"></i>
</a>
<a href="#" title="Instagram">
<i class="fab fa-instagram"></i>
</a>
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends '@novaconium/master.html.twig' %} {% extends '@novaconium/cp/control-panel.html.twig' %}
{% block content %} {% block content %}
<h1>{{title}}</h1> <h1>{{title}}</h1>

View File

@@ -1,61 +0,0 @@
{% extends '@novaconium/master.html.twig' %}
{% block content %}
<h2>Edit Page - {{ title }}</h2>
<form method="post" action="/novaconium/savePage">
<input type="hidden" name="id" value="{{ rows.id }}">
<input type="hidden" name="token" value="{{ token }}">
<label for="title">Title:</label>
<input type="text" id="title" name="title" value="{{ rows.title }}" required>
<label for="heading">Heading:</label>
<input type="text" id="heading" name="heading" value="{{ rows.heading }}">
<label for="description">Description:</label>
<input type="text" id="description" name="description" value="{{ rows.description }}">
<label for="keywords">Keywords:</label>
<input type="text" id="keywords" name="keywords" value="{{ rows.keywords }}">
<label for="author">Author:</label>
<input type="text" id="author" name="author" value="{{ rows.author }}">
<label for="slug">Slug: (<a href="/page/{{ rows.slug }}" target="_new">/page/{{ rows.slug }}</a>)</label>
<input type="text" id="slug" name="slug" value="{{ rows.slug }}" required>
<label for="path">Path:</label>
<input type="text" id="path" name="path" value="{{ rows.path }}">
<label for="intro">Intro:</label>
<textarea id="intro" name="intro" rows="5">{{ rows.intro }}</textarea>
<label for="body">Body:</label>
<textarea id="body" name="body" rows="10">{{ rows.body }}</textarea>
<label for="notes">Notes:</label>
<textarea id="notes" name="notes" rows="5">{{ rows.notes }}</textarea>
<label for="draft">
<input type="checkbox" id="draft" name="draft" value="1" {% if rows.draft %}checked{% endif %}>
Save as Draft
</label>
<label for="changefreq">Change Frequency:</label>
<select id="changefreq" name="changefreq">
{% set freqs = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'] %}
{% for freq in freqs %}
<option value="{{ freq }}" {% if rows.changefreq == freq %}selected{% endif %}>{{ freq|capitalize }}</option>
{% endfor %}
</select>
<label for="priority">Priority (0.0 - 1.0):</label>
<input type="number" id="priority" name="priority" value="{{ rows.priority }}" step="0.1" min="0" max="1">
<p><strong>Created:</strong> {{ rows.created|date("Y-m-d H:i:s") }}</p>
<p><strong>Last Updated:</strong> {{ rows.updated|date("Y-m-d H:i:s") }}</p>
<button type="submit">Save Changes</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends '@novaconium/cp/control-panel.html.twig' %}
{% block content %}
<h2>Edit Page - {{ title }}</h2>
<form method="post" action="/novaconium/savePage" id="edit-page-form-novaconium">
<input type="hidden" name="id" value="{{ rows.id }}">
<input type="hidden" name="token" value="{{ token }}">
<div class="form-group">
<button type="submit">Save Changes</button>
</div>
<div id="edit-page-title" class="form-group">
<label for="title">
Title
<span class="tooltip">?<span class="tooltiptext">This is the title for the cms, it's the default in twig, heading and metadata can be overwritten.</span></span>
</label>
<input type="text" id="title" name="title" value="{{ rows.title }}" required>
<div id="edit-page-dates">
<strong>Last Updated:</strong> {{ rows.updated|date("Y-m-d H:i:s") }},
<strong>Created:</strong> {{ rows.created|date("Y-m-d H:i:s") }}
</div>
</div>
<div class="tab-container">
<!-- Tab Navigation -->
<nav class="tab-nav">
<button type="button" class="tab-button active" onclick="switchTab('content1')">Basic Info</button>
<button type="button" class="tab-button" onclick="switchTab('content2')">SEO & Meta</button>
<button type="button" class="tab-button" onclick="switchTab('content3')">Sitemap</button>
<button type="button" class="tab-button" onclick="switchTab('content4')">Page Tweaks</button>
<button type="button" class="tab-button" onclick="switchTab('content5')">Page Notes</button>
<button type="button" class="tab-button" onclick="switchTab('content6', this)">Tags</button>
</nav>
<div id="content1" class="tab-content active">
{% include '@novacore/editpage/tab-main.html.twig' %}
</div>
<div id="content2" class="tab-content">
{% include '@novacore/editpage/tab-metadata.html.twig' %}
</div>
<div id="content3" class="tab-content">
{% include '@novacore/editpage/tab-other.html.twig' %}
</div>
<div id="content4" class="tab-content">
{% include '@novacore/editpage/tab-tweaks.html.twig' %}
</div>
<div id="content5" class="tab-content">
{% include '@novacore/editpage/tab-notes.html.twig' %}
</div>
<div id="content6" class="tab-content">
{% include '@novacore/editpage/tab-tags.html.twig' %}
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,29 @@
<div class="form-group">
<label for="slug">
Slug: (<a href="/page/{{ rows.slug }}" target="_new">/page/{{ rows.slug }}</a>)
<span class="tooltip">?<span class="tooltiptext">Slug is a human readable but uri friendly name for the page.</span></span>
</label>
<input type="text" id="slug" name="slug" value="{{ rows.slug }}" required>
</div>
<!-- Ace Editor -->
<div class="form-group fullwidth">
<label for="body">Body:</label>
<div class="editor-container">
<textarea id="body" name="body" rows="10" style="display: none;">{{ rows.body|default('')|e('html') }}</textarea>
<div id="body-editor" class="ace-editor"></div> {# Ace mounts here #}
</div>
</div>
<div class="form-group">
<label for="draft">
<input
type="checkbox"
id="draft"
name="draft"
value="1"
{% if rows.draft|default(false) %}checked{% endif %}
>
Save as draft
</label>
</div>

View File

@@ -0,0 +1,17 @@
<h2>Metadata</h2>
<div class="form-group">
<label for="description">Description:</label>
<input type="text" id="description" name="description" value="{{ rows.description }}">
</div>
<div class="form-group">
<label for="keywords">Keywords:</label>
<input type="text" id="keywords" name="keywords" value="{{ rows.keywords }}">
</div>
<div class="form-group">
<label for="author">Author:</label>
<input type="text" id="author" name="author" value="{{ rows.author }}">
</div>

View File

@@ -0,0 +1,4 @@
<div class="form-group fullwidth">
<label for="notes">Notes:</label>
<textarea id="notes" name="notes" rows="5">{{ rows.notes }}</textarea>
</div>

View File

@@ -0,0 +1,25 @@
<h2>Sitemap</h2>
<div class="form-group">
<label for="path">Path:</label>
<input type="text" id="path" name="path" value="{{ rows.path }}">
</div>
<div class="form-group">
<label for="changefreq">Change Frequency:</label>
<select id="changefreq" name="changefreq">
{% set freqs = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'] %}
{% for freq in freqs %}
<option value="{{ freq }}" {% if rows.changefreq == freq %}selected{% endif %}>
{{ freq|capitalize }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="priority">Priority (0.0 - 1.0):</label>
<input type="number" id="priority" name="priority" value="{{ rows.priority }}" step="0.1" min="0" max="1">
</div>

View File

@@ -0,0 +1,12 @@
<h1>Tags</h1>
<label for="tags">
Tags:
<span class="tooltip">?<span class="tooltiptext">Comma-separated keywords for categorizing this page (e.g., blog, tutorial). Click chips to remove; type to see suggestions.</span><span class="tooltip-desc" id="desc-tags">Comma-separated keywords for categorizing this page (e.g., blog, tutorial). Click chips to remove; type to see suggestions.</span></span>
</label>
<div class="form-row">
<div class="tags-input" id="tags-input" data-tags="{{ rows.page_tags|default('')|split(',')|filter('trim')|default([])|json_encode|e('html_attr') }}" data-existing-tags="{{ rows.existing_tags|default('')|split(',')|filter('trim')|default([])|json_encode|e('html_attr') }}">
<input type="text" id="tags" name="tags" placeholder="e.g., seo, cms, php" aria-describedby="desc-tags">
<ul id="tags-dropdown" class="tags-dropdown" role="listbox" aria-expanded="false"></ul> {# Custom dropdown #}
</div>
<input type="hidden" id="tags_json" name="tags_json" value="{{ rows.page_tags|default('')|split(',')|filter('trim')|default([])|json_encode|e('html_attr') }}">
</div>

View File

@@ -0,0 +1,10 @@
<h2>Page Data</h2>
<div class="form-group">
<label for="heading">Heading:</label>
<input type="text" id="heading" name="heading" value="{{ rows.heading }}">
</div>
<div class="form-group fullwidth">
<label for="intro">Intro:</label>
<textarea id="intro" name="intro" rows="5">{{ rows.intro }}</textarea>
</div>

View File

@@ -1,4 +1,4 @@
{% extends '@novaconium/master.html.twig' %} {% extends '@novaconium/cp/control-panel.html.twig' %}
{% block content %} {% block content %}
<h1>{{title}}</h1> <h1>{{title}}</h1>

Some files were not shown because too many files have changed in this diff Show More