Add files via upload

This commit is contained in:
2024-09-21 12:28:27 +02:00
committed by GitHub
commit ad70c4d835
6 changed files with 813 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
<?php
session_start();
$directory = __DIR__ . '/files';
if (!file_exists($directory)) {
mkdir($directory, 0755, true);
}
$directory = realpath($directory);
if ($directory === false || !is_dir($directory)) {
die("Invalid directory");
}
$files = glob($directory . '/*.md');
usort($files, function($a, $b) {
return filemtime($b) - filemtime($a);
});
$latestFiles = array_slice($files, 0, 5);
$olderFiles = array_slice($files, 5);
$alert = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['action']) && isset($_POST['csrf_token']) && $_POST['csrf_token'] === $_SESSION['csrf_token']) {
if ($_POST['action'] === 'create' || $_POST['action'] === 'edit') {
$date = date('Ymd');
$filename = $date . '-' . preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['filename']) . '.md';
$filepath = $directory . '/' . $filename;
if (strpos(realpath(dirname($filepath)), $directory) !== 0) {
die("Invalid file path");
}
$content = strip_tags($_POST['content']);
file_put_contents($filepath, $content);
$_SESSION['alert'] = [
'type' => 'success',
'message' => 'File ' . ($_POST['action'] === 'create' ? 'created' : 'updated') . ' successfully.'
];
} elseif ($_POST['action'] === 'delete') {
$filename = basename($_POST['filename']);
$filepath = $directory . '/' . $filename;
if (strpos(realpath(dirname($filepath)), $directory) !== 0) {
die("Invalid file path");
}
if (is_file($filepath) && pathinfo($filepath, PATHINFO_EXTENSION) === 'md') {
unlink($filepath);
$_SESSION['alert'] = [
'type' => 'warning',
'message' => 'File deleted successfully.'
];
}
}
}
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
$editFile = isset($_GET['edit']) ? basename($_GET['edit']) : null;
if ($editFile !== null && strpos($editFile, '..') !== false) {
$editFile = null;
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Display alert from session if it exists
if (isset($_SESSION['alert'])) {
$alertType = $_SESSION['alert']['type'];
$alertMessage = $_SESSION['alert']['message'];
$alert = "<div class='alert alert-{$alertType} alert-dismissible fade show' role='alert'>
{$alertMessage}
<button type='button' class='btn-close' data-bs-dismiss='alert' aria-label='Close'></button>
</div>";
unset($_SESSION['alert']);
}
function renderFileListItem($file) {
$basename = basename($file);
return "
<li class='list-group-item d-flex justify-content-between align-items-center'>
<div class='file-info' data-bs-toggle='modal' data-bs-target='#viewModal' data-file='" . htmlspecialchars($basename) . "'>
<span class='file-name'>" . htmlspecialchars($basename) . "</span>
<span class='file-date'>Last updated: " . date("F d, Y H:i", filemtime($file)) . "</span>
</div>
<div>
<button class='btn btn-icon view-md' data-bs-toggle='modal' data-bs-target='#viewModal' data-file='" . htmlspecialchars($basename) . "' title='View'>
<i class='fas fa-eye'></i>
</button>
<a href='?edit=" . urlencode($basename) . "' class='btn btn-icon' title='Edit'>
<i class='fas fa-edit'></i>
</a>
<button class='btn btn-icon copy-url' data-url='" . htmlspecialchars('http://' . $_SERVER['HTTP_HOST'] . dirname($_SERVER['PHP_SELF']) . '/files/' . $basename) . "' title='Copy URL'>
<i class='fas fa-copy'></i>
</button>
<a href='view_html.php?file=" . urlencode($basename) . "' class='btn btn-icon' title='View as HTML'>
<i class='fas fa-code'></i>
</a>
<button class='btn btn-icon delete-file' data-bs-toggle='modal' data-bs-target='#deleteModal' data-file='" . htmlspecialchars($basename) . "' title='Delete'>
<i class='fas fa-trash'></i>
</button>
</div>
</li>";
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vincent's MarkDown</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body class="d-flex flex-column min-vh-100">
<header class="py-2 sticky-header">
<div class="container">
<a href="./" class="nav-brand">
<strong>VINCENT</strong><span>MARKDOWN</span>
</a>
</div>
</header>
<main class="flex-grow-1">
<div class="container my-4">
<?php echo $alert; ?>
<div class="card mb-4">
<div class="card-header">
<h2 class="h5 mb-0"><?php echo $editFile ? 'Edit File' : 'Create New File'; ?></h2>
</div>
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>">
<input type="hidden" name="action" value="<?php echo $editFile ? 'edit' : 'create'; ?>">
<div class="mb-3">
<label for="filename" class="form-label">Filename:</label>
<input type="text" class="form-control" id="filename" name="filename" value="<?php echo $editFile ? substr(pathinfo($editFile, PATHINFO_FILENAME), 9) : ''; ?>" required pattern="[a-zA-Z0-9_-]+">
</div>
<div class="mb-3">
<textarea id="editor" name="content"><?php echo $editFile ? htmlspecialchars(file_get_contents($directory . '/' . $editFile)) : ''; ?></textarea>
</div>
<button type="submit" class="btn btn-primary"><?php echo $editFile ? 'Update File' : 'Create File'; ?></button>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="h5 mb-0">Markdown Files</h2>
</div>
<div class="card-body">
<ul class="list-group list-group-flush mb-4">
<?php
foreach ($latestFiles as $file) {
echo renderFileListItem($file);
}
?>
</ul>
<?php if (!empty($olderFiles)) : ?>
<div class="accordion" id="olderFilesAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="olderFilesHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#olderFilesCollapse" aria-expanded="false" aria-controls="olderFilesCollapse">
Older Files
</button>
</h2>
<div id="olderFilesCollapse" class="accordion-collapse collapse" aria-labelledby="olderFilesHeading" data-bs-parent="#olderFilesAccordion">
<div class="accordion-body p-0">
<ul class="list-group list-group-flush">
<?php
foreach ($olderFiles as $file) {
echo renderFileListItem($file);
}
?>
</ul>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</main>
<footer class="bg-white py-3 mt-auto border-top">
<div class="container text-left">
<p class="mb-0 text-muted footer-text"><small><a href="https://vincentrozenberg.com"><strong>VINCENT</strong>ROZENBERG</a> &copy;<?php echo date('Y'); ?></small></p>
</div>
</footer>
<!-- View Modal -->
<div class="modal fade" id="viewModal" tabindex="-1" aria-labelledby="viewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="viewModalLabel">View Markdown</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-end mb-3">
<a href="#" id="modalEditBtn" class="btn btn-icon me-2" title="Edit">
<i class="fas fa-edit"></i>
</a>
<button id="modalCopyUrlBtn" class="btn btn-icon me-2" title="Copy URL">
<i class="fas fa-copy"></i>
</button>
<a href="#" id="modalViewHtmlBtn" class="btn btn-icon me-2" title="View as HTML">
<i class="fas fa-code"></i>
</a>
<button class="btn btn-icon delete-file" data-bs-toggle="modal" data-bs-target="#deleteModal" data-file="" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
<div id="viewModalBody">
<!-- Rendered Markdown content will be inserted here -->
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this file?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="post" id="deleteForm">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="filename" id="deleteFilename">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
<!-- Copy URL Modal -->
<div class="modal fade" id="copyUrlModal" tabindex="-1" aria-labelledby="copyUrlModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="copyUrlModalLabel">URL Copied</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
The URL has been copied to your clipboard.
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/2.0.3/marked.min.js"></script>
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script src="scripts.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Vincent Rozenberg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80
View File
@@ -0,0 +1,80 @@
# Vincent's MarkDown
This is a quick and dirty PHP-based web application for managing and viewing Markdown files. I designed it as a straightforward solution for personal use or small-scale deployments, prioritising simplicity over advanced features.
## Disclaimer
I want to be clear: this application is a basic, no-frills solution. I didn't design it for large-scale or high-security environments. Use it at your own risk and consider implementing additional security measures for sensitive deployments.
## Features
Here's what my app can do:
- Create and edit Markdown files with an easy-to-use interface
- Show a list of all Markdown files, sorted by last modified date
- Render Markdown files as HTML for easy viewing
- Delete unwanted files
- Copy shareable URLs for each file
- View files as raw HTML
- Responsive design for both desktop and mobile devices
## Requirements
To run this, you'll need:
- PHP 7.0 or higher
- Web server (e.g., Apache, Nginx)
- Modern web browser
## Installation
1. Clone this repository to your web server's document root or a subdirectory.
2. Make sure the `files` directory is writable by your web server.
3. Configure your web server to serve PHP files.
4. Access the application through your web browser.
## Usage
Here's how to use it:
- Create a new file: Enter a filename and content in the form at the top of the page, then click "Create File".
- Edit a file: Click the edit icon next to the file name in the list.
- View a file: Click on the file name or the eye icon.
- Delete a file: Click the trash icon and confirm the deletion.
- Copy a shareable URL: Click the copy icon next to the file.
- View a file as raw HTML: Click the code icon.
## Security
I've implemented some basic security measures:
- CSRF protection for form submissions
- Input sanitisation to prevent XSS attacks
- Directory traversal prevention
- Restriction on allowed file extensions
But remember, this is a quick and dirty solution. It may not be suitable for handling sensitive information or use in high-security environments. Use at your own discretion and implement additional security measures as needed.
## Customisation
Feel free to customise the app! You can modify the appearance by editing the `styles.css` file. If you want to extend or modify the JavaScript functionality, check out the `scripts.js` file.
## External Libraries
I've used several external libraries to enhance the functionality. I'm using the latest versions available via CDN for each:
- [Bootstrap](https://getbootstrap.com/) - For responsive design and UI components
- [EasyMDE](https://easymde.tk/) - For the Markdown editor
- [Font Awesome](https://fontawesome.com/) - For icons
- [Marked](https://marked.js.org/) - For Markdown parsing
- [Google Fonts](https://fonts.google.com/) (Roboto) - For typography
I'm really grateful to the maintainers and contributors of these libraries for their excellent work.
## License
I've licensed this project under the MIT License. Check out the [LICENSE](LICENSE) file for details.
## About Me
[https://vincentrozenberg.com](https://vincentrozenberg.com).
## Contributing
Even though this is a simple, quick and dirty solution, I'm open to contributions! If you have improvements or bug fixes to suggest, please feel free to submit a Pull Request.
+68
View File
@@ -0,0 +1,68 @@
document.addEventListener('DOMContentLoaded', function() {
const easyMDE = new EasyMDE({
element: document.getElementById('editor'),
spellChecker: false,
autosave: {
enabled: true,
uniqueId: "mdEditor",
delay: 1000,
},
});
document.querySelectorAll('.copy-url').forEach(button => {
button.addEventListener('click', function(e) {
e.stopPropagation();
const url = this.getAttribute('data-url');
navigator.clipboard.writeText(url).then(() => {
const copyUrlModal = new bootstrap.Modal(document.getElementById('copyUrlModal'));
copyUrlModal.show();
});
});
});
document.querySelectorAll('.file-info, .view-md').forEach(element => {
element.addEventListener('click', function() {
const filename = this.getAttribute('data-file');
const timestamp = new Date().getTime();
fetch(`files/${encodeURIComponent(filename)}?t=${timestamp}`)
.then(response => response.text())
.then(markdown => {
const html = marked(markdown);
document.getElementById('viewModalBody').innerHTML = html;
document.getElementById('viewModalLabel').textContent = `Viewing: ${filename}`;
document.getElementById('modalEditBtn').href = `?edit=${encodeURIComponent(filename)}`;
document.getElementById('modalCopyUrlBtn').setAttribute('data-url', `${window.location.origin}${window.location.pathname}files/${encodeURIComponent(filename)}`);
document.getElementById('modalViewHtmlBtn').href = `view_html.php?file=${encodeURIComponent(filename)}`;
document.querySelector('#viewModal .delete-file').setAttribute('data-file', filename);
});
});
});
document.getElementById('modalCopyUrlBtn').addEventListener('click', function() {
const url = this.getAttribute('data-url');
navigator.clipboard.writeText(url).then(() => {
const copyUrlModal = new bootstrap.Modal(document.getElementById('copyUrlModal'));
copyUrlModal.show();
});
});
document.querySelectorAll('.delete-file').forEach(button => {
button.addEventListener('click', function(e) {
const filename = this.getAttribute('data-file');
document.getElementById('deleteFilename').value = filename;
document.getElementById('deleteModalLabel').textContent = `Confirm Deletion: ${filename}`;
});
});
// Accordion functionality
const accordion = document.getElementById('olderFilesAccordion');
if (accordion) {
accordion.addEventListener('show.bs.collapse', function () {
this.querySelector('.accordion-button').classList.remove('collapsed');
});
accordion.addEventListener('hide.bs.collapse', function () {
this.querySelector('.accordion-button').classList.add('collapsed');
});
}
});
+219
View File
@@ -0,0 +1,219 @@
body {
font-family: 'Roboto', sans-serif;
background-color: #ffffff;
color: #333333;
}
.sticky-header {
position: sticky;
top: 0;
z-index: 1000;
background-color: #ffffff;
border-bottom: 1px solid #e0e0e0;
}
.nav-brand {
font-size: 1.5rem;
text-decoration: none;
color: #333333;
}
.nav-brand strong {
font-weight: 700;
}
.nav-brand span {
font-weight: 300;
}
.btn-icon {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
color: #333333;
border: 1px solid #e0e0e0;
background-color: #ffffff;
}
.btn-icon:hover {
background-color: #f5f5f5;
}
.card {
border: 1px solid #e0e0e0;
border-radius: 0;
}
.card-header {
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
}
.list-group-item {
border: none;
border-bottom: 1px solid #e0e0e0;
padding: 1rem 0;
}
.list-group-item:last-child {
border-bottom: none;
}
.file-info {
display: flex;
flex-direction: column;
cursor: pointer;
}
.file-name {
font-weight: 500;
color: #333333;
}
.file-date {
font-size: 0.75rem;
color: #757575;
}
.modal-content {
border-radius: 0;
}
.modal-header {
border-bottom: 1px solid #e0e0e0;
}
.btn-primary {
background-color: #4b4b4b;
border-color: #333333;
}
.btn-primary:hover {
background-color: #1a1a1a;
border-color: #1a1a1a;
}
/* Styles for view_html.php */
.file-title {
font-size: 1rem;
color: #000000;
text-decoration: none;
margin-right: 0.5rem;
}
.file-title:hover {
text-decoration: underline;
}
.rendered-markdown {
line-height: 1.6;
}
.rendered-markdown h1,
.rendered-markdown h2,
.rendered-markdown h3 {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.rendered-markdown ul,
.rendered-markdown ol {
padding-left: 2rem;
margin-bottom: 1rem;
}
.rendered-markdown li {
margin-bottom: 0.5rem;
}
.rendered-markdown p {
margin-bottom: 1rem;
}
.rendered-markdown code {
background-color: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
}
/* Styles for links in rendered Markdown and modal content */
.rendered-markdown a,
#viewModalBody a {
color: #757575;
text-decoration: none;
}
.rendered-markdown a:hover,
#viewModalBody a:hover {
border-bottom: 1px dotted #757575;
}
/* Accordion styles */
.accordion-button {
background-color: #f8f9fa;
color: #333333;
font-weight: 500;
}
.accordion-button:not(.collapsed) {
background-color: #e9ecef;
color: #333333;
}
.accordion-button:focus {
box-shadow: none;
border-color: rgba(0,0,0,.125);
}
.accordion-body {
padding: 0;
}
/* Responsive styles */
@media (max-width: 767px) {
.sticky-header .container {
padding-left: 15px;
padding-right: 15px;
}
.btn-icon {
padding: 0.2rem 0.4rem;
}
}
@media (max-width: 575px) {
.file-info {
max-width: 60%;
}
.list-group-item {
flex-wrap: wrap;
}
.list-group-item > div:last-child {
margin-top: 0.5rem;
width: 100%;
display: flex;
justify-content: flex-end;
}
}
.accordion-body {
padding: 0.5em !important;
}
.footer-text a {text-decoration: none!important;
color: #757575!important;}
* {
border-radius: 0 !important;
}
a {
color: #333333 !important;
text-decoration: none !important;
}
+150
View File
@@ -0,0 +1,150 @@
<?php
$directory = __DIR__ . '/files';
$file = isset($_GET['file']) ? basename($_GET['file']) : null;
if (!$file || !is_file($directory . '/' . $file) || pathinfo($file, PATHINFO_EXTENSION) !== 'md') {
die("Invalid file");
}
$filePath = $directory . '/' . $file;
$content = file_get_contents($filePath);
function parseMarkdown($text) {
// Normalize line breaks
$text = str_replace(["\r\n", "\r"], "\n", $text);
// Split the text into lines
$lines = explode("\n", $text);
$parsed = [];
$inList = false;
$listBuffer = [];
foreach ($lines as $line) {
// Headers
if (preg_match('/^(#{1,6})\s+(.+)$/', $line, $matches)) {
$level = strlen($matches[1]);
$parsed[] = "<h$level>" . trim($matches[2]) . "</h$level>";
}
// List items
elseif (preg_match('/^(\s*[-*+])\s+(.+)$/', $line, $matches)) {
if (!$inList) {
$inList = true;
$listBuffer[] = "<ul>";
}
$listBuffer[] = "<li>" . trim($matches[2]) . "</li>";
}
// End of list
elseif ($inList && trim($line) === '') {
$inList = false;
$listBuffer[] = "</ul>";
$parsed = array_merge($parsed, $listBuffer);
$listBuffer = [];
$parsed[] = ""; // Add an empty line after the list
}
// Paragraphs
elseif (trim($line) !== '') {
$parsed[] = "<p>" . $line . "</p>";
}
// Empty lines
else {
$parsed[] = "";
}
}
// Close any open list
if ($inList) {
$listBuffer[] = "</ul>";
$parsed = array_merge($parsed, $listBuffer);
}
$text = implode("\n", $parsed);
// Inline formatting
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
$text = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $text);
$text = preg_replace('/`(.+?)`/', '<code>$1</code>', $text);
$text = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2">$1</a>', $text);
// Remove empty paragraphs
$text = preg_replace('/<p>\s*<\/p>/', '', $text);
return $text;
}
$parsedContent = parseMarkdown($content);
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http";
$directUrl = $protocol . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($file); ?> - HTML View</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body class="d-flex flex-column min-vh-100">
<header class="py-2 sticky-header">
<div class="container">
<div class="row align-items-center">
<div class="col-auto">
<a href="./" class="nav-brand">
<strong>VINCENT</strong><span>MARKDOWN</span>
</a>
</div>
<div class="col">
<div class="d-flex align-items-center">
<a href="<?php echo $directUrl; ?>" class="file-title me-2"><?php echo htmlspecialchars($file); ?></a>
<button class="btn btn-icon copy-url" data-url="<?php echo $directUrl; ?>" title="Copy URL">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
</div>
</header>
<main class="flex-grow-1">
<div class="container my-4">
<div class="rendered-markdown">
<?php echo $parsedContent; ?>
</div>
</div>
</main>
<footer class="bg-white py-3 mt-auto border-top">
<div class="container text-left">
<p class="mb-0 text-muted footer-text"><small><a href="https://vincentrozenberg.com"><strong>VINCENT</strong>ROZENBERG</a> &copy;<?php echo date('Y'); ?></small></p>
</div>
</footer>
<!-- Copy URL Modal -->
<div class="modal fade" id="copyUrlModal" tabindex="-1" aria-labelledby="copyUrlModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="copyUrlModalLabel">URL Copied</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
The URL has been copied to your clipboard.
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.querySelector('.copy-url').addEventListener('click', function() {
const url = this.getAttribute('data-url');
navigator.clipboard.writeText(url).then(() => {
const copyUrlModal = new bootstrap.Modal(document.getElementById('copyUrlModal'));
copyUrlModal.show();
});
});
</script>
</body>
</html>