This commit is contained in:
rogier committed 2025-09-28 16:05:53 +02:00
1 parent 712b15f09b
commit 6213cac146
41 files changed
+5555 -1

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
data/database/bookmarkly.db
data/images/
bookmark_*
+20
View File
@@ -0,0 +1,20 @@
FROM tobi312/php:8.4-apache
# Set DocumentRoot
ENV APACHE_DOCUMENT_ROOT /var/www/html
# Copy application files
COPY . /var/www/html
# Create data directories and set permissions
RUN mkdir -p /var/www/html/data/database /var/www/html/data/images/custom /var/www/html/data/images/backgrounds && \
chmod -R 775 /var/www/html/data
# Copy and set up entrypoint
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
# Expose port and start Apache
EXPOSE 80
CMD ["apache2-foreground"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 R0GGER
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.
+60 -1
View File
@@ -1,2 +1,61 @@
# bookmarkly2
# Bookmarkly 2.0
A clean, fast, and highly customizable self-hosted dashboard for your bookmarks.
![Screenshot](https://nedshare.nl/u/lWKywO.png)
## Key Features
- **Multi-Page Organization**: Group your bookmarks into independent pages using **Tabs**. Each tab can have its own set of categories, bookmarks, and even its own custom theme.
- **Advanced Customization**:
- **Themes**: Choose between Light, Dark, and Transparent modes.
- **Custom Backgrounds**: Set a unique background for each tab. Upload your own, use a URL, or find one with the built-in **Pexels** integration.
- **Fine-Tune**: Adjust background brightness and saturation. Add your own **Custom CSS** and footer HTML for a personalized touch.
- **Flexible Bookmark Management**:
- Organize links into **Categories** within each tab.
- Drag-and-drop sorting for both bookmarks and categories.
- **Favorites**: Mark your most-used bookmarks for quick access.
- **Custom Icons**: Upload your own icons or use the built-in favicon fetcher.
- **Backup & Restore**:
- **Full & Partial Backups**: Create a full backup of the entire site or a partial backup of a single tab—including its icons, background, and settings.
- **Seamless Importing**: Restore a full backup or import a tab backup into any existing tab.
- **Multi-User Ready**: Supports multiple users with different roles and permissions for viewing and editing tabs.
- **Easy Deployment**: Comes ready to deploy as a Docker container.
## System Maintenance
The settings page includes a "System Maintenance" section for administrative tasks. These tools help ensure data integrity and smooth updates.
- **Update Database Schema**: Safely applies any necessary database changes required by new application features. It's safe to run this multiple times.
- **Fix Orphaned Bookmarks**: This tool solves a specific data problem where bookmarks can become "orphaned" (not linked to any tab). It finds these bookmarks and automatically assigns them to your first (default) tab, making them visible and usable again.
## Getting Started (Docker)
1. Clone this repository.
2. Run `docker compose up -d --build`.
3. Access your new dashboard at `http://localhost:8080`.
**Default Login:**
- **Username:** `admin`
- **Password:** `password`
> !! It is highly recommended to change the default password after your first login !!
## Getting Started (Web Server)
You can also run Bookmarkly 2.0 on a traditional web server.
**Requirements:**
- Web server (e.g., Apache2 or Nginx)
- PHP 8.0 or newer
- SQLite PHP extension
**Steps:**
1. Clone this repository to your web server's document root.
2. Ensure the `data/` directory is writable by the web server user. This is where the database and images are stored.
3. Access the site from your browser.
**Default Login:**
- **Username:** `admin`
- **Password:** `password`
+41
View File
@@ -0,0 +1,41 @@
<?php
require_once 'include/auth.php';
// Only admins can reorder tabs
if (!isAdmin()) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
if (isset($data['tab_ids']) && is_array($data['tab_ids'])) {
$tab_ids = $data['tab_ids'];
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE tabs SET sort_order = ? WHERE id = ?");
foreach ($tab_ids as $index => $tab_id) {
// Sort order is 1-based
$sort_order = $index + 1;
$stmt->execute([$sort_order, (int)$tab_id]);
}
$pdo->commit();
header('Content-Type: application/json');
echo json_encode(['success' => true]);
} catch (Exception $e) {
$pdo->rollBack();
header('Content-Type: application/json');
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]);
}
} else {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Invalid input']);
}
+297
View File
@@ -0,0 +1,297 @@
<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Set the working directory to the project root
chdir(__DIR__ . '/..');
require_once 'include/init.php';
require_once 'include/auth.php';
// Check if the user is logged in
if (!isset($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
/**
* Recursively removes a directory and its contents.
* @param string $dir The directory to remove.
*/
function rrmdir($dir) {
if (is_dir($dir)) {
$objects = scandir($dir);
foreach ($objects as $object) {
if ($object != "." && $object != "..") {
if (is_dir($dir . DIRECTORY_SEPARATOR . $object) && !is_link($dir . DIRECTORY_SEPARATOR . $object)) {
rrmdir($dir . DIRECTORY_SEPARATOR . $object);
} else {
@unlink($dir . DIRECTORY_SEPARATOR . $object);
}
}
}
@rmdir($dir);
}
}
/**
* Creates a zip archive from a source directory.
* @param string $source The source directory.
* @param string $destination The path for the destination zip file.
* @param string &$error_message Reference to a variable to store error messages.
* @return bool True on success, false on failure.
*/
function createZipArchive($source, $destination, &$error_message) {
if (!extension_loaded('zip')) {
$error_message = 'The "zip" PHP extension is not installed or enabled.';
return false;
}
if (!file_exists($source)) {
$error_message = "Source path does not exist: " . $source;
return false;
}
$zip = new ZipArchive();
if ($zip->open($destination, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) {
$error_message = 'Failed to create zip archive. Check server permissions and logs.';
return false;
}
$source = realpath($source);
if (is_dir($source)) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($files as $file) {
$file = realpath($file);
$relativePath = str_replace($source . DIRECTORY_SEPARATOR, '', $file);
if (is_dir($file)) {
$zip->addEmptyDir($relativePath);
} else if (is_file($file)) {
$zip->addFromString($relativePath, file_get_contents($file));
}
}
}
if (!$zip->close()) {
$error_message = "Failed to write and close the zip archive. Check for disk space and permissions.";
return false;
}
return true;
}
$projectRoot = realpath(__DIR__ . '/..');
// --- Main Logic ---
$tab_id = $_GET['tab_id'] ?? 'full';
$error_message = '';
if ($tab_id === 'full') {
// --- Full Backup Logic ---
$dataDir = 'data';
$backupFileName = 'bookmarkly_full_backup_' . date('Y-m-d_H-i-s') . '.zip';
$backupFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $backupFileName;
if (createZipArchive($dataDir, $backupFilePath, $error_message)) {
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $backupFileName . '"');
header('Content-Length: ' . filesize($backupFilePath));
readfile($backupFilePath);
unlink($backupFilePath);
exit;
} else {
$_SESSION['flash_message'] = ['text' => 'Failed to create full backup. Reason: ' . $error_message, 'type' => 'danger'];
header('Location: ../settings.php');
exit;
}
} else {
// --- Tab-specific Backup Logic ---
$tab_id = filter_var($tab_id, FILTER_VALIDATE_INT);
if ($tab_id === false) {
$_SESSION['flash_message'] = ['text' => 'Invalid Tab ID.', 'type' => 'danger'];
header('Location: ../settings.php');
exit;
}
$temp_dir = sys_get_temp_dir() . '/bookmarkly_tab_backup_' . uniqid();
if (!mkdir($temp_dir, 0777, true)) {
$_SESSION['flash_message'] = ['text' => 'Failed to create temporary directory for backup.', 'type' => 'danger'];
header('Location: ../settings.php');
exit;
}
try {
// --- START DEBUGGING ---
$log_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'bookmarkly_backup_debug.log';
$log_file = fopen($log_path, 'w');
if ($log_file) {
fwrite($log_file, "--- Starting Tab Backup Debug ---\n");
fwrite($log_file, "Log file location: " . $log_path . "\n");
fwrite($log_file, "Time: " . date('c') . "\n");
fwrite($log_file, "Project Root: " . $projectRoot . "\n\n");
}
// --- END DEBUGGING ---
// 1. Fetch data for the specific tab from the main DB
$tab_stmt = $pdo->prepare("SELECT * FROM tabs WHERE id = ?");
$tab_stmt->execute([$tab_id]);
$tab = $tab_stmt->fetch(PDO::FETCH_ASSOC);
if (!$tab) {
throw new Exception("Tab with ID {$tab_id} not found.");
}
$cat_stmt = $pdo->prepare("SELECT * FROM categories WHERE tab_id = ?");
$cat_stmt->execute([$tab_id]);
$categories = $cat_stmt->fetchAll(PDO::FETCH_ASSOC);
$book_stmt = $pdo->prepare("SELECT * FROM bookmarks WHERE tab_id = ?");
$book_stmt->execute([$tab_id]);
$bookmarks = $book_stmt->fetchAll(PDO::FETCH_ASSOC);
// 2. Create temporary structure and database
$temp_db_path = $temp_dir . '/database/bookmarkly.db';
mkdir($temp_dir . '/database', 0777, true);
mkdir($temp_dir . '/images/custom', 0777, true); // CORRECTED: Create the 'custom' directory
mkdir($temp_dir . '/images/backgrounds', 0777, true);
$backup_pdo = new PDO('sqlite:' . $temp_db_path);
$backup_pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Create schema in backup DB
$backup_pdo->exec("CREATE TABLE tabs (id INTEGER PRIMARY KEY, name TEXT, background_image_url TEXT, background_brightness INTEGER, background_saturation INTEGER, theme TEXT, theme_transparency INTEGER)");
$backup_pdo->exec("CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT, sort_order INTEGER, tab_id INTEGER)");
$backup_pdo->exec("CREATE TABLE bookmarks (id INTEGER PRIMARY KEY, category_id INTEGER, title TEXT, url TEXT, icon TEXT, is_favorite INTEGER, sort_order INTEGER, tab_id INTEGER)");
// 3. Copy files and insert data into backup DB
// Handle Tab Background
$new_background_path = $tab['background_image_url'];
if (!empty($tab['background_image_url']) && strpos($tab['background_image_url'], 'data/images/backgrounds/') === 0) {
$source_path_relative = strtok($tab['background_image_url'], '?');
$source_path_absolute = $projectRoot . DIRECTORY_SEPARATOR . $source_path_relative;
if (file_exists($source_path_absolute)) {
$bg_filename = basename($source_path_relative);
$new_background_path_in_db = 'images/backgrounds/' . $bg_filename;
$destination_path = $temp_dir . DIRECTORY_SEPARATOR . $new_background_path_in_db;
if (copy($source_path_absolute, $destination_path)) {
$new_background_path = $new_background_path_in_db;
}
}
}
// Insert Tab data
$insert_tab_stmt = $backup_pdo->prepare("INSERT INTO tabs VALUES (?, ?, ?, ?, ?, ?, ?)");
$insert_tab_stmt->execute([$tab['id'], $tab['name'], $new_background_path, $tab['background_brightness'], $tab['background_saturation'], $tab['theme'], $tab['theme_transparency']]);
// Handle Categories and Bookmarks
$insert_cat_stmt = $backup_pdo->prepare("INSERT INTO categories VALUES (?, ?, ?, ?)");
foreach ($categories as $category) {
$insert_cat_stmt->execute([$category['id'], $category['name'], $category['sort_order'], $category['tab_id']]);
}
$insert_book_stmt = $backup_pdo->prepare("INSERT INTO bookmarks VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
foreach ($bookmarks as $bookmark) {
$icon_path_for_db = $bookmark['icon']; // Start with the original path
// Only process locally stored custom icons
if (!empty($bookmark['icon']) && strpos($bookmark['icon'], 'data/images/custom/') === 0) {
// --- START DEBUGGING ---
if ($log_file) fwrite($log_file, "Processing bookmark ID: " . $bookmark['id'] . " Title: '" . $bookmark['title'] . "'\n");
// --- END DEBUGGING ---
$relative_source_path = strtok($bookmark['icon'], '?');
$absolute_source_path = $projectRoot . DIRECTORY_SEPARATOR . $relative_source_path;
// --- START DEBUGGING ---
if ($log_file) {
fwrite($log_file, " - DB Path: " . $bookmark['icon'] . "\n");
fwrite($log_file, " - Relative Path Used: " . $relative_source_path . "\n");
fwrite($log_file, " - Absolute Path Checked: " . $absolute_source_path . "\n");
$file_exists_result = file_exists($absolute_source_path);
fwrite($log_file, " - file_exists() Result: " . ($file_exists_result ? 'TRUE' : 'FALSE') . "\n");
}
// --- END DEBUGGING ---
if ($file_exists_result) {
$filename = basename($relative_source_path);
// The new path inside the zip archive's structure
$relative_dest_path = 'images/custom/' . $filename;
// The absolute path to copy the file to in the temp directory
$absolute_dest_path = $temp_dir . DIRECTORY_SEPARATOR . $relative_dest_path;
// Copy the file and if successful, update the path to be stored in the backup DB
if (copy($absolute_source_path, $absolute_dest_path)) {
$icon_path_for_db = $relative_dest_path;
if ($log_file) fwrite($log_file, " - ACTION: File copied successfully.\n\n");
} else {
if ($log_file) fwrite($log_file, " - ACTION: copy() FAILED.\n\n");
}
} else {
if ($log_file) fwrite($log_file, " - ACTION: File skipped.\n\n");
}
}
$insert_book_stmt->execute([$bookmark['id'], $bookmark['category_id'], $bookmark['title'], $bookmark['url'], $icon_path_for_db, $bookmark['is_favorite'], $bookmark['sort_order'], $bookmark['tab_id']]);
}
// 4. Create manifest.json
$manifest = [
'type' => 'tab',
'tab_id' => $tab['id'],
'tab_name' => $tab['name'],
'version' => '2.0',
'timestamp' => date('c')
];
file_put_contents($temp_dir . '/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT));
// 5. Zip and send the backup
$safe_tab_name = preg_replace('/[^a-zA-Z0-9_-]/', '_', $tab['name']);
$backupFileName = "bookmarkly_tab_{$safe_tab_name}_backup_" . date('Y-m-d') . '.zip';
$backupFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $backupFileName;
if (createZipArchive($temp_dir, $backupFilePath, $error_message)) {
// --- START DEBUGGING ---
if ($log_file) {
fwrite($log_file, "--- Backup zip created successfully. ---");
fclose($log_file);
}
// --- END DEBUGGING ---
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $backupFileName . '"');
header('Content-Length: ' . filesize($backupFilePath));
readfile($backupFilePath);
unlink($backupFilePath);
rrmdir($temp_dir);
exit;
} else {
// --- START DEBUGGING ---
if ($log_file) {
fwrite($log_file, "--- FAILED to create zip archive. Error: " . $error_message . " ---");
fclose($log_file);
}
// --- END DEBUGGING ---
throw new Exception('Failed to create zip archive. ' . $error_message);
}
} catch (Exception $e) {
// --- START DEBUGGING ---
if (isset($log_file) && is_resource($log_file)) {
fwrite($log_file, "--- CATCH BLOCK ERROR: " . $e->getMessage() . " ---");
fclose($log_file);
}
// --- END DEBUGGING ---
rrmdir($temp_dir); // Clean up on error
$_SESSION['flash_message'] = ['text' => 'Error creating tab backup: ' . $e->getMessage(), 'type' => 'danger'];
header('Location: ../settings.php');
exit;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
require_once '../include/init.php';
require_once '../include/auth.php'; // ensures user is logged in and is admin
header('Content-Type: application/json');
if (!isAdmin()) {
http_response_code(403); // Forbidden
echo json_encode(['error' => 'You do not have permission to perform this action.']);
exit;
}
$tab_id = $_POST['tab_id'] ?? null;
if (!$tab_id) {
http_response_code(400); // Bad Request
echo json_encode(['error' => 'Tab ID is required.']);
exit;
}
try {
// Set the token to NULL in the database
$stmt = $pdo->prepare("UPDATE tabs SET login_token = NULL WHERE id = ?");
$stmt->execute([$tab_id]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(500); // Internal Server Error
echo json_encode(['error' => 'Failed to clear token: ' . $e->getMessage()]);
}
+39
View File
@@ -0,0 +1,39 @@
<?php
require_once '../include/init.php';
require_once '../include/auth.php';
header('Content-Type: application/json');
// Security Check: Only Admin can access
if (!isAdmin()) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'You do not have permission to perform this action.']);
exit;
}
try {
// Find the default tab (first one by sort order or ID) to assign orphans to
$stmt = $pdo->query("SELECT id FROM tabs ORDER BY sort_order ASC, id ASC LIMIT 1");
$default_tab_id = $stmt->fetchColumn();
if (!$default_tab_id) {
throw new Exception("No default tab found to assign bookmarks to.");
}
$update_stmt = $pdo->prepare("UPDATE bookmarks SET tab_id = ? WHERE tab_id IS NULL OR tab_id = '' OR tab_id = 0");
$update_stmt->execute([$default_tab_id]);
$count = $update_stmt->rowCount();
if ($count > 0) {
$message = "Successfully assigned {$count} orphaned bookmarks to the default tab (ID: {$default_tab_id}).";
} else {
$message = "No orphaned bookmarks were found.";
}
echo json_encode(['success' => true, 'message' => $message, 'updated_count' => $count]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]);
}
+33
View File
@@ -0,0 +1,33 @@
<?php
require_once '../include/init.php';
require_once '../include/auth.php'; // ensures user is logged in and is admin
header('Content-Type: application/json');
if (!isAdmin()) {
http_response_code(403); // Forbidden
echo json_encode(['error' => 'You do not have permission to perform this action.']);
exit;
}
$tab_id = $_POST['tab_id'] ?? null;
if (!$tab_id) {
http_response_code(400); // Bad Request
echo json_encode(['error' => 'Tab ID is required.']);
exit;
}
try {
// Generate a unique token
$token = bin2hex(random_bytes(16));
// Store the token in the database
$stmt = $pdo->prepare("UPDATE tabs SET login_token = ? WHERE id = ?");
$stmt->execute([$token, $tab_id]);
echo json_encode(['success' => true, 'token' => $token]);
} catch (Exception $e) {
http_response_code(500); // Internal Server Error
echo json_encode(['error' => 'Failed to generate token: ' . $e->getMessage()]);
}
+205
View File
@@ -0,0 +1,205 @@
<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Set the working directory to the project root
chdir(__DIR__ . '/..');
// --- Custom Error Handling ---
register_shutdown_function(function () {
$error = error_get_last();
if ($error !== null && !headers_sent()) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'A fatal server error occurred.',
'error_details' => $error
]);
}
});
require_once 'include/init.php';
require_once 'include/auth.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'You must be logged in.']);
exit;
}
/**
* Recursively removes a directory and its contents.
* @param string $dir The directory to remove.
*/
function rrmdir($dir) {
if (is_dir($dir)) {
$objects = scandir($dir);
foreach ($objects as $object) {
if ($object != "." && $object != "..") {
is_dir($dir . DIRECTORY_SEPARATOR . $object) ? rrmdir($dir . DIRECTORY_SEPARATOR . $object) : unlink($dir . DIRECTORY_SEPARATOR . $object);
}
}
rmdir($dir);
}
}
// --- Main Logic ---
try {
if (!isset($_FILES['backup_file']) || $_FILES['backup_file']['error'] !== UPLOAD_ERR_OK) {
throw new Exception('File upload error.');
}
if (!isset($_POST['target_tab_id']) || empty($_POST['target_tab_id'])) {
throw new Exception('Target tab not selected.');
}
$target_tab_id = (int)$_POST['target_tab_id'];
$backup_file = $_FILES['backup_file']['tmp_name'];
$temp_dir = sys_get_temp_dir() . '/bookmarkly_import_' . uniqid();
if (!mkdir($temp_dir, 0777, true)) {
throw new Exception('Failed to create temporary directory.');
}
$zip = new ZipArchive;
if ($zip->open($backup_file) !== TRUE) {
rrmdir($temp_dir);
throw new Exception('Failed to open the backup file.');
}
$zip->extractTo($temp_dir);
$zip->close();
$backup_db_path = $temp_dir . '/database/bookmarkly.db';
$manifest_path = $temp_dir . '/manifest.json';
if (!file_exists($backup_db_path)) {
throw new Exception('Invalid backup file: bookmarkly.db not found.');
}
$backup_pdo = new PDO('sqlite:' . $backup_db_path);
$backup_pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$backup_pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// --- Begin Transaction ---
$pdo->beginTransaction();
try {
$is_tab_backup = false;
if (file_exists($manifest_path)) {
$manifest = json_decode(file_get_contents($manifest_path), true);
if (isset($manifest['type']) && $manifest['type'] === 'tab') {
$is_tab_backup = true;
}
}
// --- Import Logic ---
$old_to_new_category_id_map = [];
$imported_categories_count = 0;
$imported_bookmarks_count = 0;
// If it's a tab backup, also import tab settings and files
if ($is_tab_backup) {
$backup_tab = $backup_pdo->query("SELECT * FROM tabs LIMIT 1")->fetch();
if ($backup_tab) {
// Handle background image
$new_background_url = $backup_tab['background_image_url'];
if (!empty($new_background_url) && strpos($new_background_url, 'images/backgrounds/') === 0) {
$source_bg = $temp_dir . '/' . $new_background_url;
if (file_exists($source_bg)) {
$bg_filename = basename($new_background_url);
$dest_dir = 'data/images/backgrounds/';
if (!is_dir($dest_dir)) mkdir($dest_dir, 0755, true);
$unique_filename = 'tab' . $target_tab_id . '_' . time() . '_' . $bg_filename;
$dest_bg = $dest_dir . $unique_filename;
if (copy($source_bg, $dest_bg)) {
$new_background_url = $dest_bg . '?v=' . time();
}
}
}
// Update target tab with settings from backup
$update_tab_stmt = $pdo->prepare(
"UPDATE tabs SET theme = ?, theme_transparency = ?, background_image_url = ?, background_brightness = ?, background_saturation = ? WHERE id = ?"
);
$update_tab_stmt->execute([
$backup_tab['theme'],
$backup_tab['theme_transparency'],
$new_background_url,
$backup_tab['background_brightness'],
$backup_tab['background_saturation'],
$target_tab_id
]);
}
}
// Import Categories
$categories = $backup_pdo->query("SELECT * FROM categories")->fetchAll();
$stmt_insert_category = $pdo->prepare("INSERT INTO categories (name, sort_order, tab_id) VALUES (?, ?, ?)");
foreach ($categories as $category) {
$stmt_insert_category->execute([$category['name'], $category['sort_order'], $target_tab_id]);
$old_to_new_category_id_map[$category['id']] = $pdo->lastInsertId();
$imported_categories_count++;
}
// Import Bookmarks
$bookmarks = $backup_pdo->query("SELECT * FROM bookmarks")->fetchAll();
$stmt_insert_bookmark = $pdo->prepare("INSERT INTO bookmarks (category_id, title, url, icon, is_favorite, sort_order, tab_id) VALUES (?, ?, ?, ?, ?, ?, ?)");
foreach ($bookmarks as $bookmark) {
// Determine the new category ID, allowing for bookmarks without a category
$new_category_id = null;
if (!empty($bookmark['category_id']) && isset($old_to_new_category_id_map[$bookmark['category_id']])) {
$new_category_id = $old_to_new_category_id_map[$bookmark['category_id']];
}
// Handle icon
$new_icon_path = $bookmark['icon'];
// In tab backups, icon paths are relative to the zip root, e.g., 'images/custom/icon.png'
if ($is_tab_backup && !empty($new_icon_path) && strpos($new_icon_path, 'images/custom/') === 0) {
$source_icon = $temp_dir . '/' . $new_icon_path;
if (file_exists($source_icon)) {
$icon_filename = basename($new_icon_path);
$dest_dir = 'data/images/custom/'; // The correct destination for custom icons
if (!is_dir($dest_dir)) mkdir($dest_dir, 0755, true);
$unique_filename = 'imported_' . time() . '_' . $icon_filename;
$dest_icon = $dest_dir . $unique_filename;
if (copy($source_icon, $dest_icon)) {
$new_icon_path = $dest_icon;
}
}
}
$stmt_insert_bookmark->execute([
$new_category_id, $bookmark['title'], $bookmark['url'],
$new_icon_path, $bookmark['is_favorite'], $bookmark['sort_order'], $target_tab_id
]);
$imported_bookmarks_count++;
}
$pdo->commit();
$message = "Import successful. Added {$imported_categories_count} categories and {$imported_bookmarks_count} bookmarks.";
echo json_encode(['success' => true, 'message' => $message]);
} catch (Exception $e) {
$pdo->rollBack();
throw new Exception('Database error during import: ' . $e->getMessage());
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
} finally {
if (isset($temp_dir) && is_dir($temp_dir)) {
rrmdir($temp_dir);
}
// Close DB connections
$pdo = null;
if(isset($backup_pdo)) {
$backup_pdo = null;
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
session_start();
// Basic authentication check
if (!isset($_SESSION['user_id'])) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Authentication required.']);
exit;
}
// Check if image path is provided
if (!isset($_POST['path']) || empty($_POST['path'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Image path not provided.']);
exit;
}
$imagePath = $_POST['path'];
// Sanitize the path: remove any query string to prevent issues with realpath()
$path_parts = parse_url($imagePath);
$imagePath = $path_parts['path'];
$baseDir = realpath(__DIR__ . '/../data/images/backgrounds');
$fullPath = realpath(__DIR__ . '/../' . $imagePath);
// Security check: ensure the file is within the backgrounds directory
if (!$fullPath || strpos($fullPath, $baseDir) !== 0 || !file_exists($fullPath)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Invalid or non-existent image path.']);
exit;
}
// --- Image Optimization Logic ---
$maxWidth = 1920;
$quality = 85;
try {
// Get image info
list($width, $height, $type) = getimagesize($fullPath);
// Don't optimize if the image is already reasonably sized
if ($width <= $maxWidth) {
// Still, re-save JPEG to apply quality setting, which might reduce size
if ($type == IMAGETYPE_JPEG) {
$source = imagecreatefromjpeg($fullPath);
imagejpeg($source, $fullPath, $quality);
imagedestroy($source);
echo json_encode(['success' => true, 'message' => 'Image quality adjusted, but no resize needed.']);
exit;
}
// For other types, if no resize is needed, there's nothing to do.
echo json_encode(['success' => true, 'message' => 'Image is already optimized (width is within limit).']);
exit;
}
// Continue with resizing for images wider than maxWidth
$newWidth = $maxWidth;
$newHeight = (int) (($newWidth / $width) * $height);
// Create image resource based on type
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($fullPath);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($fullPath);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($fullPath);
break;
case IMAGETYPE_WEBP:
$source = imagecreatefromwebp($fullPath);
break;
default:
throw new Exception('Unsupported image type.');
}
if ($source === false) {
throw new Exception('Could not create image resource.');
}
// Create a new true color image
$thumb = imagecreatetruecolor($newWidth, $newHeight);
// Handle transparency for PNG
if ($type == IMAGETYPE_PNG) {
imagealphablending($thumb, false);
imagesavealpha($thumb, true);
$transparent = imagecolorallocatealpha($thumb, 255, 255, 255, 127);
imagefilledrectangle($thumb, 0, 0, $newWidth, $newHeight, $transparent);
}
// Resize
imagecopyresampled($thumb, $source, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
// Save the optimized image, overwriting the original
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($thumb, $fullPath, $quality);
break;
case IMAGETYPE_PNG:
// PNG quality is 0-9. 85 quality -> level 1-2.
imagepng($thumb, $fullPath, 1);
break;
case IMAGETYPE_GIF:
imagegif($thumb, $fullPath);
break;
case IMAGETYPE_WEBP:
imagewebp($thumb, $fullPath, $quality);
break;
}
// Free up memory
imagedestroy($source);
imagedestroy($thumb);
echo json_encode(['success' => true, 'message' => 'Image optimized successfully.']);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Error optimizing image: ' . $e->getMessage()]);
}
+42
View File
@@ -0,0 +1,42 @@
<?php
// API endpoint for searching Pexels
// This should be called via AJAX from the settings page.
require_once __DIR__ . '/../include/auth.php'; // Reuse auth check
header('Content-Type: application/json');
// Get API Key from settings
$stmt = $pdo->prepare("SELECT value FROM settings WHERE key = ?");
$stmt->execute(['pexels_api_key']);
$api_key = $stmt->fetchColumn();
if (empty($api_key)) {
echo json_encode(['error' => 'Pexels API key is not set in settings.']);
exit;
}
$query = trim($_GET['query'] ?? '');
if (empty($query)) {
echo json_encode(['error' => 'Search query is required.']);
exit;
}
$url = "https://api.pexels.com/v1/search?query=" . urlencode($query) . "&per_page=15";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: ' . $api_key
]);
$result = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code !== 200) {
echo json_encode(['error' => 'Failed to fetch images from Pexels.', 'details' => json_decode($result)]);
exit;
}
echo $result;
+68
View File
@@ -0,0 +1,68 @@
<?php
require_once '../include/init.php';
require_once '../include/auth.php';
// --- Security Check: Only Admin can access this API ---
if (!isAdmin()) {
http_response_code(403); // Forbidden
echo json_encode(['error' => 'Permission denied.']);
exit;
}
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$response = [];
try {
if ($method === 'GET') {
$tab_id = filter_input(INPUT_GET, 'tab_id', FILTER_VALIDATE_INT);
if ($tab_id) {
$stmt = $pdo->prepare("SELECT user_id FROM tab_viewer_access WHERE tab_id = ?");
$stmt->execute([$tab_id]);
// Fetch just the user_id column into a simple array
$response = $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
} else {
http_response_code(400); // Bad Request
$response = ['error' => 'Tab ID is required.'];
}
} elseif ($method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
$tab_id = $data['tab_id'] ?? null;
$viewer_ids = $data['viewer_ids'] ?? [];
if ($tab_id && is_array($viewer_ids)) {
// Start transaction
$pdo->beginTransaction();
// 1. Delete existing entries for this tab
$delete_stmt = $pdo->prepare("DELETE FROM tab_viewer_access WHERE tab_id = ?");
$delete_stmt->execute([$tab_id]);
// 2. Insert new entries
if (!empty($viewer_ids)) {
$insert_stmt = $pdo->prepare("INSERT INTO tab_viewer_access (tab_id, user_id) VALUES (?, ?)");
foreach ($viewer_ids as $user_id) {
$insert_stmt->execute([$tab_id, $user_id]);
}
}
// Commit transaction
$pdo->commit();
$response = ['success' => true];
} else {
http_response_code(400); // Bad Request
$response = ['error' => 'Tab ID and a list of viewer IDs are required.'];
}
} else {
http_response_code(405); // Method Not Allowed
$response = ['error' => 'Method not allowed'];
}
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500); // Internal Server Error
$response = ['error' => 'Database error: ' . $e->getMessage()];
}
echo json_encode($response);
+52
View File
@@ -0,0 +1,52 @@
<?php
require_once '../include/init.php';
require_once '../include/auth.php';
// --- Security Check: Only Admin can access this API ---
if (!isAdmin()) {
http_response_code(403); // Forbidden
echo json_encode(['error' => 'Permission denied.']);
exit;
}
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$response = [];
try {
if ($method === 'GET') {
// Get all hidden tab settings
$stmt = $pdo->query("SELECT user_id, tab_id FROM user_tab_visibility");
$response = $stmt->fetchAll();
} elseif ($method === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
$tab_id = $data['tab_id'] ?? null;
$user_id = $data['user_id'] ?? null;
$is_visible = $data['is_visible'] ?? true;
if ($tab_id && $user_id) {
if ($is_visible) {
// If it should be visible, DELETE the entry from the hide table
$stmt = $pdo->prepare("DELETE FROM user_tab_visibility WHERE user_id = ? AND tab_id = ?");
$stmt->execute([$user_id, $tab_id]);
} else {
// If it should be hidden, INSERT an entry (ignore if it already exists)
$stmt = $pdo->prepare("INSERT OR IGNORE INTO user_tab_visibility (user_id, tab_id) VALUES (?, ?)");
$stmt->execute([$user_id, $tab_id]);
}
$response = ['success' => true];
} else {
http_response_code(400); // Bad Request
$response = ['error' => 'Tab ID and User ID are required.'];
}
} else {
http_response_code(405); // Method Not Allowed
$response = ['error' => 'Method not allowed'];
}
} catch (PDOException $e) {
http_response_code(500); // Internal Server Error
$response = ['error' => 'Database error: ' . $e->getMessage()];
}
echo json_encode($response);
+105
View File
@@ -0,0 +1,105 @@
<?php
require_once '../include/init.php';
require_once '../include/auth.php'; // ensures user is logged in
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$response = [];
try {
switch ($method) {
case 'GET':
// Get all tabs
$stmt = $pdo->query("SELECT * FROM tabs ORDER BY id");
$response = $stmt->fetchAll();
break;
case 'POST':
// Create a new tab
$data = json_decode(file_get_contents('php://input'), true);
if (isset($data['name'])) {
$stmt = $pdo->prepare("INSERT INTO tabs (name, user_id) VALUES (?, ?)");
$stmt->execute([$data['name'], $data['user_id'] ?? null]);
$response = ['id' => $pdo->lastInsertId(), 'name' => $data['name']];
} else {
http_response_code(400); // Bad Request
$response = ['error' => 'Name is required'];
}
break;
case 'PUT':
// Update a tab
$data = json_decode(file_get_contents('php://input'), true);
if (isset($data['id']) && isset($data['name'])) {
// Prepare fields to update
$fields = [
'name' => $data['name'],
'user_id' => $data['user_id'] ?? null,
'background_image_url' => $data['background_image_url'] ?? null,
'background_brightness' => $data['background_brightness'] ?? 100,
'background_saturation' => $data['background_saturation'] ?? 100,
'theme' => $data['theme'] ?? null,
'theme_transparency' => $data['theme_transparency'] ?? null,
'column_count' => $data['column_count'] ?? 5,
];
// Handle empty strings to set them to NULL for proper fallback
if ($fields['background_image_url'] === '') {
$fields['background_image_url'] = null;
}
if ($fields['theme'] === '') {
$fields['theme'] = null;
}
if ($fields['theme_transparency'] === '' || !is_numeric($fields['theme_transparency'])) {
$fields['theme_transparency'] = null;
}
$stmt = $pdo->prepare("
UPDATE tabs
SET
name = :name,
user_id = :user_id,
background_image_url = :background_image_url,
background_brightness = :background_brightness,
background_saturation = :background_saturation,
theme = :theme,
theme_transparency = :theme_transparency,
column_count = :column_count
WHERE id = :id
");
$fields['id'] = $data['id'];
$stmt->execute($fields);
$response = ['success' => true];
} else {
http_response_code(400); // Bad Request
$response = ['error' => 'ID and name are required'];
}
break;
case 'DELETE':
// Delete a tab
$data = json_decode(file_get_contents('php://input'), true);
if (isset($data['id'])) {
$stmt = $pdo->prepare("DELETE FROM tabs WHERE id = ?");
$stmt->execute([$data['id']]);
$response = ['success' => true];
} else {
http_response_code(400); // Bad Request
$response = ['error' => 'ID is required'];
}
break;
default:
http_response_code(405); // Method Not Allowed
$response = ['error' => 'Method not allowed'];
break;
}
} catch (PDOException $e) {
http_response_code(500); // Internal Server Error
$response = ['error' => 'Database error: ' . $e->getMessage()];
}
echo json_encode($response);
+89
View File
@@ -0,0 +1,89 @@
<?php
require_once '../include/init.php';
require_once '../include/auth.php';
header('Content-Type: application/json');
// --- Security Check: Only Admin can access this page ---
if (!isAdmin()) {
http_response_code(403); // Forbidden
echo json_encode(['success' => false, 'message' => 'You do not have permission to perform this action.']);
exit;
}
try {
$messages = [];
// --- Check columns in 'tabs' table ---
$tabs_columns = $pdo->query("PRAGMA table_info(tabs)")->fetchAll(PDO::FETCH_COLUMN, 1);
$required_columns = [
'background_image_url' => 'TEXT DEFAULT NULL',
'background_brightness' => 'INTEGER DEFAULT 100',
'background_saturation' => 'INTEGER DEFAULT 100',
'theme' => 'TEXT DEFAULT NULL',
'theme_transparency' => 'INTEGER DEFAULT NULL',
'login_token' => 'VARCHAR(255)'
];
$columns_added = false;
foreach ($required_columns as $column_name => $column_definition) {
if (!in_array($column_name, $tabs_columns)) {
$pdo->exec("ALTER TABLE tabs ADD COLUMN $column_name $column_definition");
$messages[] = "Column '$column_name' added to 'tabs' table.";
$columns_added = true;
} else {
$messages[] = "Column '$column_name' already exists in 'tabs' table.";
}
}
// Check for login_token column and add unique index if not present
$current_tabs_columns = $pdo->query("PRAGMA table_info(tabs)")->fetchAll(PDO::FETCH_COLUMN, 1);
if (in_array('login_token', $current_tabs_columns)) {
$indexes = $pdo->query("PRAGMA index_list(tabs)")->fetchAll();
$index_exists = false;
foreach ($indexes as $index) {
// Check if the index is on the login_token column
$index_info = $pdo->query("PRAGMA index_info({$index['name']})")->fetchAll();
foreach ($index_info as $info) {
if ($info['name'] === 'login_token' && $index['unique']) {
$index_exists = true;
break 2;
}
}
}
if (!$index_exists) {
$tabs_without_token = $pdo->query("SELECT id FROM tabs WHERE login_token IS NULL OR login_token = ''")->fetchAll(PDO::FETCH_COLUMN);
if (!empty($tabs_without_token)) {
$update_stmt = $pdo->prepare("UPDATE tabs SET login_token = ? WHERE id = ?");
foreach ($tabs_without_token as $tab_id) {
$token = bin2hex(random_bytes(16));
$update_stmt->execute([$token, $tab_id]);
}
$messages[] = count($tabs_without_token) . " existing tabs updated with a unique login token.";
$columns_added = true;
}
try {
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tabs_login_token ON tabs(login_token)");
$messages[] = "Added unique index to login_token column.";
$columns_added = true;
} catch (PDOException $e) {
$messages[] = "Could not create unique index on login_token: " . $e->getMessage();
}
} else {
$messages[] = "Unique index on login_token already exists.";
}
}
if ($columns_added) {
echo json_encode(['success' => true, 'message' => 'Database updated successfully!', 'details' => $messages]);
} else {
echo json_encode(['success' => true, 'message' => 'Database is already up to date.', 'details' => $messages]);
}
} catch (PDOException $e) {
http_response_code(500); // Internal Server Error
echo json_encode(['success' => false, 'message' => 'Database error: ' . $e->getMessage()]);
}
+47
View File
@@ -0,0 +1,47 @@
<?php
// API endpoint for updating sort order for categories or bookmarks
require_once __DIR__ . '/../include/auth.php';
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['type']) || !isset($input['order']) || !is_array($input['order'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
$type = $input['type'];
$order = $input['order'];
$table_name = '';
if ($type === 'category') {
$table_name = 'categories';
} elseif ($type === 'bookmark') {
$table_name = 'bookmarks';
} else {
http_response_code(400);
echo json_encode(['error' => 'Invalid type specified.']);
exit;
}
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE {$table_name} SET sort_order = ? WHERE id = ?");
foreach ($order as $index => $id) {
if (is_numeric($id)) {
$stmt->execute([$index + 1, $id]);
}
}
$pdo->commit();
echo json_encode(['success' => true, 'message' => ucfirst($type) . ' order updated.']);
} catch (Exception $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Database error: ' . $e->getMessage()]);
}
+47
View File
@@ -0,0 +1,47 @@
<?php
// API endpoint for updating sort order
require_once __DIR__ . '/../include/auth.php';
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['type']) || !isset($data['order'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
$type = $data['type'];
$order = $data['order'];
if (!is_array($order)) {
http_response_code(400);
echo json_encode(['error' => 'Order data must be an array.']);
exit;
}
$table = '';
if ($type === 'category') {
$table = 'categories';
} elseif ($type === 'bookmark') {
$table = 'bookmarks';
} else {
http_response_code(400);
echo json_encode(['error' => 'Invalid type specified.']);
exit;
}
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE {$table} SET sort_order = ? WHERE id = ?");
foreach ($order as $index => $id) {
if (!is_numeric($id)) continue; // Basic sanitization
$stmt->execute([$index + 1, $id]);
}
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Database update failed.', 'details' => $e->getMessage()]);
}
+96
View File
@@ -0,0 +1,96 @@
<?php
require_once '../include/init.php';
require_once '../include/auth.php';
// --- Security Check: Only Admin can access this page ---
if (!isAdmin()) {
header('Content-Type: application/json');
http_response_code(403); // Forbidden
echo json_encode(['success' => false, 'message' => 'You do not have permission to perform this action.']);
exit;
}
header('Content-Type: application/json');
$response = [
'success' => false,
'message' => 'An unknown error occurred.',
'path' => null
];
$uploadDir = '../data/images/backgrounds/';
// Create directory if it doesn't exist
if (!is_dir($uploadDir)) {
if (!mkdir($uploadDir, 0755, true)) {
http_response_code(500);
$response['message'] = 'Failed to create upload directory.';
echo json_encode($response);
exit;
}
}
if (isset($_FILES['background']) && $_FILES['background']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['background'];
$tabId = isset($_POST['tab_id']) ? intval($_POST['tab_id']) : 0;
// --- Validation ---
// Size check (e.g., 5MB limit)
if ($file['size'] > 5 * 1024 * 1024) {
http_response_code(400);
$response['message'] = 'File is too large. Maximum size is 5MB.';
echo json_encode($response);
exit;
}
// Type check
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$fileInfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($fileInfo, $file['tmp_name']);
finfo_close($fileInfo);
if (!in_array($mimeType, $allowedTypes)) {
http_response_code(400);
$response['message'] = 'Invalid file type. Only JPG, PNG, GIF, and WEBP are allowed.';
echo json_encode($response);
exit;
}
// --- File Processing ---
// Find and delete any existing background for this tab
$existing_files = glob($uploadDir . 'tab_' . $tabId . '.*');
foreach ($existing_files as $file_to_delete) {
unlink($file_to_delete);
}
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$fileName = 'tab_' . $tabId . '.' . $extension;
$destination = $uploadDir . $fileName;
if (move_uploaded_file($file['tmp_name'], $destination)) {
$response['success'] = true;
$response['message'] = 'File uploaded successfully.';
// Return the path relative to the project root with a cache-busting query string
$response['path'] = 'data/images/backgrounds/' . $fileName . '?v=' . time();
} else {
http_response_code(500);
$response['message'] = 'Failed to move uploaded file.';
}
} else {
http_response_code(400);
switch ($_FILES['background']['error'] ?? UPLOAD_ERR_NO_FILE) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$response['message'] = 'File is too large.';
break;
case UPLOAD_ERR_NO_FILE:
$response['message'] = 'No file was uploaded.';
break;
default:
$response['message'] = 'An error occurred during upload.';
break;
}
}
echo json_encode($response);
+677
View File
@@ -0,0 +1,677 @@
<?php
$page_title = 'Manage Bookmarks - Bookmarkly';
require_once 'include/auth.php';
// --- Security Check: Only Admin and User roles can access this page ---
if (!isAdmin() && !isUser()) {
header('Location: index.php');
exit;
}
// Flash messages for feedback
$message = '';
$message_type = '';
if (isset($_SESSION['flash_message'])) {
$message = $_SESSION['flash_message']['text'];
$message_type = $_SESSION['flash_message']['type'];
unset($_SESSION['flash_message']);
}
// --- Helper function for icon uploads ---
function handle_icon_upload($bookmark_id = null, $title = '') {
if (isset($_FILES['icon_upload']) && $_FILES['icon_upload']['error'] === UPLOAD_ERR_OK) {
$upload_dir = 'data/images/custom/';
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0755, true);
}
$file_info = pathinfo($_FILES['icon_upload']['name']);
$extension = strtolower($file_info['extension']);
$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'ico'];
if (in_array($extension, $allowed_extensions)) {
// Sanitize title for filename
$sanitized_title = trim($title);
if (empty($sanitized_title)) {
$sanitized_title = 'bookmark';
} else {
$sanitized_title = strtolower($sanitized_title);
$sanitized_title = str_replace(' ', '_', $sanitized_title);
// Remove characters that are not letters, numbers, underscores, or hyphens
$sanitized_title = preg_replace('/[^a-z0-9_-]/', '', $sanitized_title);
// Truncate to a reasonable length
$sanitized_title = substr($sanitized_title, 0, 50);
}
$filename = 'bookmark_' . ($bookmark_id ?? 'id') . '_' . $sanitized_title . '.' . $extension;
$destination = $upload_dir . $filename;
// Before moving, if we are editing, we should delete the old file.
if ($bookmark_id) {
global $pdo;
$stmt = $pdo->prepare("SELECT icon FROM bookmarks WHERE id = ?");
$stmt->execute([$bookmark_id]);
$old_icon = $stmt->fetchColumn();
// Check if old icon exists, is custom, and is not the same path as the new one
if ($old_icon && file_exists($old_icon) && strpos($old_icon, 'data/images/custom/') === 0 && $old_icon !== $destination) {
unlink($old_icon);
}
}
if (move_uploaded_file($_FILES['icon_upload']['tmp_name'], $destination)) {
return $destination; // Return the path to be saved in DB
}
}
}
return null; // No upload or failed upload
}
// Handle POST requests for CRUD operations
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check edit permissions for the submitted tab_id for all actions
$submitted_tab_id = $_POST['tab_id'] ?? $_GET['tab_id'] ?? null;
if (isset($_POST['action']) && !canEditTab($submitted_tab_id)) {
$_SESSION['flash_message'] = ['text' => 'You do not have permission to perform this action.', 'type' => 'danger'];
header('Location: bookmarks.php?tab_id=' . $submitted_tab_id);
exit;
}
$action = $_POST['action'] ?? '';
// --- Category Actions ---
if ($action === 'add_category') {
$category_name = trim($_POST['category_name'] ?? '');
$tab_id = filter_input(INPUT_POST, 'tab_id', FILTER_VALIDATE_INT);
if (!empty($category_name) && $tab_id) {
$sort_stmt = $pdo->prepare("SELECT MAX(sort_order) as max_sort FROM categories WHERE tab_id = ?");
$sort_stmt->execute([$tab_id]);
$max_sort = $sort_stmt->fetchColumn() ?: 0;
$stmt = $pdo->prepare("INSERT INTO categories (name, sort_order, tab_id) VALUES (?, ?, ?)");
if ($stmt->execute([$category_name, $max_sort + 1, $tab_id])) {
$_SESSION['flash_message'] = ['text' => 'Category added successfully.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error adding category.', 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Category name and tab ID are required.', 'type' => 'warning'];
}
} elseif ($action === 'delete_category') {
$category_id = filter_input(INPUT_POST, 'category_id', FILTER_VALIDATE_INT);
if ($category_id) {
$stmt = $pdo->prepare("DELETE FROM categories WHERE id = ?");
if ($stmt->execute([$category_id])) {
$_SESSION['flash_message'] = ['text' => 'Category and its bookmarks have been deleted.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error deleting category.', 'type' => 'danger'];
}
}
} elseif ($action === 'edit_category') {
$category_id = filter_input(INPUT_POST, 'category_id', FILTER_VALIDATE_INT);
$category_name = trim($_POST['category_name'] ?? '');
if ($category_id && !empty($category_name)) {
$stmt = $pdo->prepare("UPDATE categories SET name = ? WHERE id = ?");
if ($stmt->execute([$category_name, $category_id])) {
$_SESSION['flash_message'] = ['text' => 'Category renamed successfully.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error renaming category.', 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Invalid data for renaming category.', 'type' => 'warning'];
}
}
// --- Bookmark Actions ---
elseif ($action === 'add_bookmark') {
$title = trim($_POST['title'] ?? '');
$url = trim($_POST['url'] ?? '');
if (!empty($url) && !preg_match('/^https?:\/\//i', $url)) {
$url = 'https://' . $url;
}
$category_id = filter_input(INPUT_POST, 'category_id', FILTER_VALIDATE_INT);
$icon_url = trim($_POST['icon_url'] ?? '');
$is_favorite = isset($_POST['is_favorite']) ? 1 : 0;
$icon = $icon_url; // Default to URL
$new_icon_path = handle_icon_upload();
if ($new_icon_path) {
$icon = $new_icon_path;
}
if ($title && $url && $category_id) {
$sort_stmt = $pdo->prepare("SELECT MAX(sort_order) as max_sort FROM bookmarks WHERE category_id = ?");
$sort_stmt->execute([$category_id]);
$max_sort = $sort_stmt->fetchColumn() ?: 0;
$stmt = $pdo->prepare(
"INSERT INTO bookmarks (category_id, title, url, icon, is_favorite, sort_order)
VALUES (?, ?, ?, ?, ?, ?)"
);
if ($stmt->execute([$category_id, $title, $url, $icon, $is_favorite, $max_sort + 1])) {
$bookmark_id = $pdo->lastInsertId(); // Get the ID of the new bookmark
// Handle icon upload after getting the ID
$new_icon_path = handle_icon_upload($bookmark_id, $title);
if ($new_icon_path) {
// Update the bookmark with the new icon path
$update_stmt = $pdo->prepare("UPDATE bookmarks SET icon = ? WHERE id = ?");
$update_stmt->execute([$new_icon_path, $bookmark_id]);
}
$_SESSION['flash_message'] = ['text' => 'Bookmark added successfully.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error adding bookmark.', 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Title, URL, and category are required.', 'type' => 'warning'];
}
} elseif ($action === 'delete_bookmark') {
$bookmark_id = filter_input(INPUT_POST, 'bookmark_id', FILTER_VALIDATE_INT);
if ($bookmark_id) {
$stmt = $pdo->prepare("DELETE FROM bookmarks WHERE id = ?");
if ($stmt->execute([$bookmark_id])) {
$_SESSION['flash_message'] = ['text' => 'Bookmark deleted.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error deleting bookmark.', 'type' => 'danger'];
}
}
}
elseif ($action === 'edit_bookmark') {
$bookmark_id = filter_input(INPUT_POST, 'bookmark_id', FILTER_VALIDATE_INT);
$title = trim($_POST['title'] ?? '');
$url = trim($_POST['url'] ?? '');
if (!empty($url) && !preg_match('/^https?:\/\//i', $url)) {
$url = 'https://' . $url;
}
$category_id = filter_input(INPUT_POST, 'category_id', FILTER_VALIDATE_INT);
$icon_url = trim($_POST['icon_url'] ?? '');
$is_favorite = isset($_POST['is_favorite']) ? 1 : 0;
$remove_icon = ($_POST['remove_icon'] ?? '0') === '1';
if ($bookmark_id && $title && $url && $category_id) {
// Fetch current icon to manage file deletion
$stmt = $pdo->prepare("SELECT icon FROM bookmarks WHERE id = ?");
$stmt->execute([$bookmark_id]);
$current_icon = $stmt->fetchColumn();
$icon = $current_icon; // Keep old icon by default
$new_icon_path = handle_icon_upload($bookmark_id, $title);
if ($new_icon_path) {
// 1. New upload takes highest precedence
$icon = $new_icon_path;
} else if ($remove_icon) {
// 2. Remove icon takes next precedence
if ($current_icon && file_exists($current_icon) && strpos($current_icon, 'data/images/custom/') === 0) {
unlink($current_icon);
}
// After removing, use whatever is in the URL field (might be empty)
$icon = $icon_url;
} else {
// 3. No upload, no removal. Handle URL input field.
// If the current icon is NOT a custom file, we can just save the icon_url.
if (strpos($current_icon ?? '', 'data/images/custom/') !== 0) {
$icon = $icon_url;
}
// If it IS a custom file, we should only change it if the user provided a new icon_url.
else if (!empty($icon_url)) {
// User is switching from custom to URL. Delete the old file.
if (file_exists($current_icon)) {
unlink($current_icon);
}
$icon = $icon_url;
}
// Implicit else: current icon is custom, icon_url is empty. Do nothing to keep the custom icon.
}
$stmt = $pdo->prepare(
"UPDATE bookmarks SET title = ?, url = ?, category_id = ?, icon = ?, is_favorite = ? WHERE id = ?"
);
if ($stmt->execute([$title, $url, $category_id, $icon, $is_favorite, $bookmark_id])) {
$_SESSION['flash_message'] = ['text' => 'Bookmark updated successfully.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error updating bookmark.', 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Invalid data for updating bookmark.', 'type' => 'warning'];
}
}
// Redirect to the same page to prevent form resubmission, preserving the tab
$tab_id = $_POST['tab_id'] ?? $_GET['tab_id'] ?? 1;
header('Location: bookmarks.php?tab_id=' . $tab_id);
exit;
}
// Fetch visible tabs for the user
$current_user_id = $_SESSION['user_id'] ?? null;
$current_user_role = $_SESSION['role'] ?? null;
$tabs_stmt_sql = "SELECT DISTINCT t.* FROM tabs t ";
$params = [];
// Logic to determine which tabs are visible based on role and explicit permissions
$visibility_conditions = "
LEFT JOIN user_tab_visibility utv ON t.id = utv.tab_id AND utv.user_id = :current_user_id
WHERE utv.user_id IS NULL
";
if ($current_user_id) {
if ($current_user_role === 'admin') {
// Admin sees all tabs that are not explicitly hidden for them
$tabs_stmt_sql .= $visibility_conditions;
} else { // 'user' and 'viewer' roles
// Users/Viewers see public tabs, their own tabs, or tabs they have access to,
// as long as they are not explicitly hidden.
$tabs_stmt_sql .= "
LEFT JOIN tab_viewer_access tva ON t.id = tva.tab_id
{$visibility_conditions}
AND (t.user_id IS NULL OR t.user_id = :current_user_id OR tva.user_id = :current_user_id)
";
}
$params[':current_user_id'] = $current_user_id;
} else {
// Guest sees only public tabs
$tabs_stmt_sql .= "WHERE t.user_id IS NULL";
}
$tabs_stmt_sql .= " ORDER BY t.id";
$tabs_stmt = $pdo->prepare($tabs_stmt_sql);
$tabs_stmt->execute($params);
$tabs = $tabs_stmt->fetchAll();
// Fetch all categories and their bookmarks for display for the current tab
$current_tab_id = $_GET['tab_id'] ?? ($tabs[0]['id'] ?? 1);
// Check if the current user can edit the currently viewed tab
$can_edit = canEditTab($current_tab_id);
$stmt = $pdo->prepare("
SELECT
c.id as category_id,
c.name as category_name,
c.sort_order as category_sort_order,
b.id as bookmark_id,
b.title as bookmark_title,
b.url as bookmark_url,
b.icon as bookmark_icon,
b.is_favorite as bookmark_is_favorite,
b.sort_order as bookmark_sort_order
FROM categories c
LEFT JOIN bookmarks b ON c.id = b.category_id
WHERE c.tab_id = :tab_id
ORDER BY c.sort_order, c.name, b.sort_order, b.title
");
$stmt->execute(['tab_id' => $current_tab_id]);
$results = $stmt->fetchAll();
$categories = [];
foreach ($results as $row) {
if (!isset($categories[$row['category_id']])) {
$categories[$row['category_id']] = [
'id' => $row['category_id'],
'name' => $row['category_name'],
'bookmarks' => []
];
}
if ($row['bookmark_id']) {
$categories[$row['category_id']]['bookmarks'][] = [
'id' => $row['bookmark_id'],
'title' => $row['bookmark_title'],
'url' => $row['bookmark_url'],
'icon' => $row['bookmark_icon'],
'is_favorite' => $row['bookmark_is_favorite']
];
}
}
require_once 'include/header.php';
?>
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1><?php echo translate('bookmarks_manage_title'); ?></h1>
<div>
<?php if (isAdmin()): ?>
<a href="tabs_settings.php" class="btn btn-outline-secondary">
<i class="bi bi-folder-symlink"></i> Manage Tabs
</a>
<?php endif; ?>
<?php if ($can_edit): ?>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addBookmarkModal">
<i class="bi bi-plus-lg"></i> <?php echo translate('bookmarks_add_bookmark_button'); ?>
</button>
<?php endif; ?>
</div>
</div>
<!-- Tab Navigation -->
<?php if (count($tabs) > 1): ?>
<ul class="nav nav-tabs mb-3">
<?php foreach ($tabs as $tab): ?>
<li class="nav-item">
<a class="nav-link <?php if ($tab['id'] == $current_tab_id) echo 'active'; ?>" href="?tab_id=<?php echo $tab['id']; ?>">
<?php echo htmlspecialchars($tab['name'] === 'Default' ? translate('tab_default_name') : $tab['name']); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($message): ?>
<div class="alert alert-<?php echo $message_type; ?> alert-dismissible fade show" role="alert">
<?php echo htmlspecialchars($message); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<div class="row">
<!-- Categories Column -->
<div class="col-md-5">
<h3><?php echo translate('bookmarks_categories'); ?></h3>
<div class="card">
<div class="card-body">
<!-- Add Category Form -->
<?php if ($can_edit): ?>
<form action="bookmarks.php" method="post" class="mb-3">
<input type="hidden" name="action" value="add_category">
<input type="hidden" name="tab_id" value="<?php echo $current_tab_id; ?>">
<div class="input-group">
<input type="text" name="category_name" class="form-control" placeholder="<?php echo translate('bookmarks_new_category_placeholder'); ?>" required>
<button class="btn btn-primary" type="submit"><i class="bi bi-plus-lg"></i> <?php echo translate('bookmarks_add_category_button'); ?></button>
</div>
</form>
<?php endif; ?>
<!-- Category List -->
<ul class="list-group" id="category-list">
<?php foreach ($categories as $category): ?>
<li class="list-group-item d-flex justify-content-between align-items-center" data-id="<?php echo $category['id']; ?>">
<div>
<i class="bi bi-grip-vertical sort-handle" style="cursor: move;"></i> <!-- Handle for sorting -->
<span class="ms-2"><?php echo htmlspecialchars($category['name']); ?></span>
</div>
<div>
<span class="badge bg-secondary rounded-pill me-2"><?php echo count($category['bookmarks']); ?></span>
<?php if ($can_edit): ?>
<button class="btn btn-sm btn-outline-secondary" title="Edit Category"
data-bs-toggle="modal"
data-bs-target="#editCategoryModal"
data-bs-id="<?php echo $category['id']; ?>"
data-bs-name="<?php echo htmlspecialchars($category['name']); ?>">
<i class="bi bi-pencil"></i>
</button>
<form action="bookmarks.php" method="post" class="d-inline" onsubmit="return confirm('<?php echo translate('bookmarks_delete_category_confirm'); ?>');">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_id" value="<?php echo $category['id']; ?>">
<input type="hidden" name="tab_id" value="<?php echo $current_tab_id; ?>">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete Category"><i class="bi bi-trash"></i></button>
</form>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
</div>
<!-- Bookmarks Column -->
<div class="col-md-7">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3><?php echo translate('bookmarks_bookmarks'); ?></h3>
</div>
<?php if (empty($categories)): ?>
<div class="card">
<div class="card-body text-center">
<p class="text-muted"><?php echo translate('bookmarks_no_category_text'); ?></p>
</div>
</div>
<?php endif; ?>
<?php foreach ($categories as $category): ?>
<div class="card mb-3">
<div class="card-header">
<?php echo htmlspecialchars($category['name']); ?>
</div>
<div class="card-body">
<?php if (empty($category['bookmarks'])): ?>
<p class="text-muted"><?php echo translate('bookmarks_category_empty'); ?></p>
<?php else: ?>
<ul class="list-group list-group-flush bookmark-list" data-category-id="<?php echo $category['id']; ?>">
<?php foreach ($category['bookmarks'] as $bookmark): ?>
<?php
$icon_src = 'include/images/default-icon.png'; // Default icon
if (!empty($bookmark['icon'])) {
// Check if it's a custom uploaded icon
if (file_exists($bookmark['icon']) && strpos($bookmark['icon'], 'data/images/custom/') === 0) {
$icon_src = htmlspecialchars($bookmark['icon']); // Add timestamp to prevent caching
}
// Check if the icon stored is a full URL
elseif (filter_var($bookmark['icon'], FILTER_VALIDATE_URL)) {
$icon_src = htmlspecialchars($bookmark['icon']);
} elseif (!empty($settings['favicon_extractor_url'])) {
// It's a domain, build the URL from the base
$base_url = rtrim($settings['favicon_extractor_url'], '/');
$domain = htmlspecialchars($bookmark['icon']);
$icon_src = "{$base_url}/favicon/{$domain}?larger=true";
}
}
?>
<li class="list-group-item d-flex justify-content-between align-items-center" data-id="<?php echo $bookmark['id']; ?>">
<span>
<i class="bi bi-grip-vertical sort-handle" style="cursor: move;"></i>
<img src="<?php echo $icon_src; ?>" class="ms-2 me-2" style="width: 16px; height: 16px;" onerror="this.onerror=null;this.src='include/images/default-icon.png';">
<a href="<?php echo htmlspecialchars($bookmark['url']); ?>" <?php if($settings['open_new_tab'] ?? '1' === '1') echo 'target="_blank"'; ?>><?php echo htmlspecialchars($bookmark['title']); ?></a>
<?php if ($bookmark['is_favorite']): ?>
<i class="bi bi-star-fill text-warning" title="Favorite"></i>
<?php endif; ?>
</span>
<div>
<?php if ($can_edit): ?>
<button class="btn btn-sm btn-outline-secondary"
title="Edit Bookmark"
data-bs-toggle="modal"
data-bs-target="#editBookmarkModal"
data-bs-id="<?php echo $bookmark['id']; ?>"
data-bs-title="<?php echo htmlspecialchars($bookmark['title']); ?>"
data-bs-url="<?php echo htmlspecialchars($bookmark['url']); ?>"
data-bs-category="<?php echo $category['id']; ?>"
data-bs-icon="<?php echo htmlspecialchars($bookmark['icon']); ?>"
data-bs-icon-src="<?php echo $icon_src; ?>"
data-bs-favorite="<?php echo $bookmark['is_favorite']; ?>">
<i class="bi bi-pencil"></i>
</button>
<form action="bookmarks.php" method="post" class="d-inline" onsubmit="return confirm('<?php echo translate('bookmarks_delete_bookmark_confirm'); ?>');">
<input type="hidden" name="action" value="delete_bookmark">
<input type="hidden" name="bookmark_id" value="<?php echo $bookmark['id']; ?>">
<input type="hidden" name="tab_id" value="<?php echo $current_tab_id; ?>">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete Bookmark"><i class="bi bi-trash"></i></button>
</form>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Add Bookmark Modal -->
<div class="modal fade" id="addBookmarkModal" tabindex="-1" aria-labelledby="addBookmarkModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addBookmarkModalLabel"><?php echo translate('modal_add_bookmark_title'); ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="bookmarks.php" method="POST" enctype="multipart/form-data">
<div class="modal-body">
<input type="hidden" name="action" value="add_bookmark">
<input type="hidden" name="tab_id" value="<?php echo $current_tab_id; ?>">
<div class="mb-3">
<label for="bookmark-title" class="form-label"><?php echo translate('modal_title'); ?></label>
<input type="text" class="form-control" id="bookmark-title" name="title" required>
</div>
<div class="mb-3">
<label for="bookmark-url" class="form-label"><?php echo translate('modal_url'); ?></label>
<input type="text" class="form-control" id="bookmark-url" name="url" required>
</div>
<div class="mb-3">
<label for="bookmark-category" class="form-label"><?php echo translate('modal_category'); ?></label>
<select class="form-select" id="bookmark-category" name="category_id">
<option value="" selected><?php echo translate('modal_select_category'); ?></option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo htmlspecialchars($cat['id']); ?>"><?php echo htmlspecialchars($cat['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="bookmark-icon-url" class="form-label"><?php echo translate('modal_icon_url'); ?></label>
<div class="input-group">
<input type="text" class="form-control" id="bookmark-icon-url" name="icon_url">
<button class="btn btn-outline-secondary" type="button" id="fetch-favicon-add" title="<?php echo translate('modal_fetch_favicon_button'); ?>">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="form-text"><?php echo translate('modal_icon_url_help'); ?></div>
</div>
<div class="mb-3">
<label for="bookmark-icon-upload" class="form-label"><?php echo translate('modal_icon_upload'); ?></label>
<input class="form-control" type="file" id="bookmark-icon-upload" name="icon_upload" accept="image/*">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="1" id="bookmark-favorite" name="is_favorite">
<label class="form-check-label" for="bookmark-favorite">
<?php echo translate('modal_mark_as_favorite'); ?>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo translate('modal_close_button'); ?></button>
<button type="submit" class="btn btn-primary"><?php echo translate('modal_save_bookmark_button'); ?></button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Bookmark Modal -->
<div class="modal fade" id="editBookmarkModal" tabindex="-1" aria-labelledby="editBookmarkModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editBookmarkModalLabel"><?php echo translate('modal_edit_bookmark_title'); ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="bookmarks.php" method="POST" enctype="multipart/form-data">
<div class="modal-body">
<input type="hidden" name="action" value="edit_bookmark">
<input type="hidden" name="bookmark_id" id="edit-bookmark-id">
<input type="hidden" name="tab_id" value="<?php echo $current_tab_id; ?>">
<div class="mb-3">
<label for="edit-bookmark-title" class="form-label"><?php echo translate('modal_title'); ?></label>
<input type="text" class="form-control" id="edit-bookmark-title" name="title" required>
</div>
<div class="mb-3">
<label for="edit-bookmark-url" class="form-label"><?php echo translate('modal_url'); ?></label>
<input type="text" class="form-control" id="edit-bookmark-url" name="url" required>
</div>
<div class="mb-3">
<label for="edit-bookmark-category" class="form-label"><?php echo translate('modal_category'); ?></label>
<select class="form-select" id="edit-bookmark-category" name="category_id" required>
<?php foreach ($categories as $category): ?>
<option value="<?php echo $category['id']; ?>"><?php echo htmlspecialchars($category['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="edit-bookmark-icon-url" class="form-label"><?php echo translate('modal_icon_url'); ?></label>
<div class="input-group">
<input type="text" class="form-control" id="edit-bookmark-icon-url" name="icon_url">
<button class="btn btn-outline-secondary" type="button" id="fetch-favicon-edit" title="<?php echo translate('modal_fetch_favicon_button'); ?>">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="form-text"><?php echo translate('modal_icon_url_help'); ?></div>
</div>
<div class="mb-3">
<label for="edit-bookmark-icon-upload" class="form-label"><?php echo translate('modal_icon_upload'); ?></label>
<input class="form-control" type="file" id="edit-bookmark-icon-upload" name="icon_upload" accept="image/*">
</div>
<div class="mb-3" id="current-icon-container">
<label class="form-label"><?php echo translate('modal_current_icon'); ?></label>
<div>
<img id="current-icon-preview" src="" alt="Current Icon" style="max-width: 32px; max-height: 32px; margin-right: 10px;">
<button type="button" id="remove-custom-icon" class="btn btn-sm btn-danger"><?php echo translate('modal_remove_icon_button'); ?></button>
<input type="hidden" name="remove_icon" id="remove-icon-input" value="0">
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="1" id="edit-bookmark-favorite" name="is_favorite">
<label class="form-check-label" for="edit-bookmark-favorite">
<?php echo translate('modal_mark_as_favorite'); ?>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo translate('modal_close_button'); ?></button>
<button type="submit" class="btn btn-primary"><?php echo translate('modal_save_changes_button'); ?></button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Category Modal -->
<div class="modal fade" id="editCategoryModal" tabindex="-1" aria-labelledby="editCategoryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editCategoryModalLabel"><?php echo translate('modal_edit_category_title'); ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="bookmarks.php" method="POST">
<div class="modal-body">
<input type="hidden" name="action" value="edit_category">
<input type="hidden" name="category_id" id="edit-category-id">
<input type="hidden" name="tab_id" value="<?php echo $current_tab_id; ?>">
<div class="mb-3">
<label for="edit-category-name" class="form-label"><?php echo translate('modal_category_name'); ?></label>
<input type="text" class="form-control" id="edit-category-name" name="category_name" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo translate('modal_close_button'); ?></button>
<button type="submit" class="btn btn-primary"><?php echo translate('modal_save_changes_button'); ?></button>
</div>
</form>
</div>
</div>
</div>
<?php require_once 'include/footer.php'; ?>
+3
View File
@@ -0,0 +1,3 @@
<RequireAll>
Require all denied
</RequireAll>
+30
View File
@@ -0,0 +1,30 @@
services:
php:
build: .
container_name: bookmarkly2
restart: unless-stopped
ports:
- "8080:80"
volumes:
- .:/var/www/html:rw
environment:
TZ: "Europe/Amsterdam"
PHP_ERRORS: 1
PHP_MEM_LIMIT: 128
PHP_POST_MAX_SIZE: 250
PHP_UPLOAD_MAX_FILESIZE: 25
PHP_MAX_FILE_UPLOADS: 20
CREATE_PHPINFO_FILE: 0
CREATE_INDEX_FILE: 0
ENABLE_APACHE_REWRITE: 1
ENABLE_APACHE_ACTIONS: 0
ENABLE_APACHE_SSL: 0
ENABLE_APACHE_HEADERS: 0
ENABLE_APACHE_ALLOWOVERRIDE: 1
ENABLE_APACHE_REMOTEIP: 0
ENABLE_APACHE_STATUS: 0
# favicon-downloader:
# ports:
# - 3000:3000
# image: seadfeng/favicon-downloader
# restart: always
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
# Set permissions on the data directory
chown -R www-data:www-data /var/www/html/data
chmod -R 775 /var/www/html/data
# Execute the CMD from the Dockerfile, or the command passed to `docker run`
exec "$@"
+92
View File
@@ -0,0 +1,92 @@
<?php
require_once __DIR__ . '/init.php';
// Determine which page is being accessed
$current_page = basename($_SERVER['PHP_SELF']);
// These pages always require authentication
$protected_pages = ['bookmarks.php', 'settings.php', 'api/pexels.php', 'api/update_order.php'];
// Assume authentication is not required by default
$auth_required = false;
// Check if the current page must be protected
if (in_array($current_page, $protected_pages)) {
$auth_required = true;
} else if ($current_page === 'index.php') {
// index.php is only protected if the setting is enabled
try {
$stmt = $pdo->query("SELECT value FROM settings WHERE key = 'dashboard_protected'");
$dashboard_protected = $stmt->fetchColumn();
if ($dashboard_protected === '1') {
$auth_required = true;
}
} catch (Exception $e) {
// If the setting can't be read, default to requiring authentication for safety
$auth_required = true;
}
}
// If authentication is required for the page, check for a valid session
if ($auth_required && !isset($_SESSION['user_id']) && !isset($_SESSION['guest_tab_id'])) {
// For API calls, return a JSON error instead of redirecting
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') {
header('Content-Type: application/json');
http_response_code(401);
echo json_encode(['error' => 'Authentication required.']);
exit;
}
// For regular pages, redirect to login
header('Location: /login.php');
exit;
}
// --- Role-based Access Control (RBAC) Helper Functions ---
/**
* Checks if the current logged-in user is an admin.
* @return bool
*/
function isAdmin() {
return isset($_SESSION['role']) && $_SESSION['role'] === 'admin';
}
/**
* Checks if the current logged-in user is a standard user.
* @return bool
*/
function isUser() {
return isset($_SESSION['role']) && $_SESSION['role'] === 'user';
}
/**
* Checks if the current logged-in user is a viewer.
* @return bool
*/
function isViewer() {
return isset($_SESSION['role']) && $_SESSION['role'] === 'viewer';
}
/**
* Checks if the current user can edit a specific tab.
* Admins can edit any tab.
* Users can only edit tabs they own.
* @param int $tab_id The ID of the tab to check.
* @return bool
*/
function canEditTab($tab_id) {
if (isAdmin()) {
return true;
}
if (isUser()) {
global $pdo;
$stmt = $pdo->prepare("SELECT id FROM tabs WHERE id = ? AND user_id = ?");
$stmt->execute([$tab_id, $_SESSION['user_id']]);
if ($stmt->fetch()) {
return true;
}
}
return false;
}
+189
View File
@@ -0,0 +1,189 @@
/* Custom CSS for Bookmarkly */
html {
font-size: 14px;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/* Background properties will be set dynamically via settings */
}
@media (min-width: 1280px) {
.container {
max-width: 1400px;
}
}
body[style*="background-image"] {
background-color: transparent !important;
}
.h5 {
font-weight: 600 !important;
}
.bookmark-link {
background-color: transparent !important;
border: none !important;
padding: 0.5rem 1rem 0.5rem 0 !important;
color: inherit !important;
font-weight: 500 !important;
transition: transform 0.2s ease-in-out;
}
.bookmark-link:hover {
transform: translateY(-3px);
}
.bookmark-link > div {
min-width: 0;
}
.bookmark-link span {
display: block;
}
.bookmark-link span,
.bookmark-link small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-icon {
width: 14px;
height: 14px;
object-fit: contain;
}
.bookmark-favorite-icon {
width: 48px;
height: 48px;
object-fit: contain;
}
.category-column {
background-color: rgba(var(--bs-body-bg-rgb), var(--theme-transparency, 0.85));
backdrop-filter: blur(10px);
padding: 15px;
border-radius: 8px;
height: 100%;
}
.list-group-item {
background-color: transparent !important;
border: none !important;
padding: 0.5rem 0 !important;
color: inherit !important;
text-align: left;
transition: transform 0.2s ease-in-out;
font-weight: 400 !important;
font-size: 0.9rem !important;
}
.list-group-item-action:hover {
background-color: transparent !important;
transform: translateX(5px);
}
.list-group-item span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 0.5rem; /* Space between logo and title */
color: #f8f9fa !important;
font-size: 1.5rem; /* Increased font size for site title */
}
/* Class to force light text color when background is dark */
body.dark-background-active {
color: #f8f9fa !important;
}
body.dark-background-active h1,
body.dark-background-active h2,
body.dark-background-active h3,
body.dark-background-active h4,
body.dark-background-active h5,
body.dark-background-active .card,
body.dark-background-active .nav-link,
body.dark-background-active label {
color: #fff !important;
}
body.dark-background-active .form-range::-webkit-slider-thumb {
background: #fff;
}
body.dark-background-active .form-range::-moz-range-thumb {
background: #fff;
}
body.dark-background-active .text-muted {
color: rgba(255, 255, 255, 0.6) !important;
font-weight: 100 !important;
}
.sort-handle {
cursor: move;
}
/* Make links more readable on dark backgrounds */
body.dark-background-active .bookmark-list a {
color: var(--bs-light);
}
body.dark-background-active .bookmark-list a:hover {
color: var(--bs-gray-400);
}
[data-bs-theme="dark"] .form-control {
background-color: var(--bs-dark);
color: var(--bs-light);
border-color: var(--bs-gray-700);
}
[data-bs-theme="dark"] .form-control:focus {
background-color: var(--bs-dark);
color: var(--bs-light);
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
}
.bookmark-list a {
text-decoration: none;
}
.bookmark-list a:hover {
text-decoration: underline;
}
#v-pills-tab .nav-link {
text-align: left;
}
body.dark-background-active .form-text,
[data-bs-theme="dark"] .form-text {
color: rgba(255, 255, 255, 0.55);
}
[data-bs-theme="dark"] .list-group-item {
background-color: var(--bs-gray-800);
border-color: var(--bs-gray-700);
}
/* Custom styles for transparent theme */
[data-bs-theme="transparent"] {
}
+18
View File
@@ -0,0 +1,18 @@
<footer class="mt-auto py-3 text-center">
<div class="container">
<?php if (!empty($settings['custom_footer_html'])): ?>
<?php echo $settings['custom_footer_html']; ?>
<?php else: ?>
<span class="text-muted">Made with ❤️ in Holland</span>
<?php endif; ?>
</div>
</footer>
<script>
const faviconExtractorUrl = '<?php echo $settings['favicon_extractor_url'] ?? ''; ?>';
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<script src="include/js/script.js"></script>
</body>
</html>
+150
View File
@@ -0,0 +1,150 @@
<?php
// This file assumes $pdo is available from init.php,
// and that an auth check has already been performed.
// It also assumes the $settings array and optionally $current_tab_data have been pre-loaded.
// Initialize variables with global settings as defaults
$theme = $settings['theme'] ?? 'light';
$bg_url = $settings['background_image_url'] ?? '';
$bg_brightness = $settings['background_brightness'] ?? '100';
$bg_saturation = $settings['background_saturation'] ?? '100';
$theme_transparency = $settings['theme_transparency'] ?? '85';
$custom_css = $settings['custom_css'] ?? '';
// --- Tab-Specific Override ---
// Overwrite the variables if a tab-specific setting is available.
if (isset($current_tab_data)) {
if (!empty($current_tab_data['theme'])) {
$theme = $current_tab_data['theme'];
}
if (isset($current_tab_data['theme_transparency']) && $current_tab_data['theme_transparency'] !== null) {
$theme_transparency = $current_tab_data['theme_transparency'];
}
// Background settings are only overridden if a URL is explicitly set for the tab.
if (!empty($current_tab_data['background_image_url'])) {
$bg_url = $current_tab_data['background_image_url'];
$bg_brightness = $current_tab_data['background_brightness'];
$bg_saturation = $current_tab_data['background_saturation'];
}
}
$body_classes = ['theme-' . htmlspecialchars($theme)];
if ($theme !== 'dark' && intval($bg_brightness) < 70) {
$body_classes[] = 'dark-background-active';
}
$navbar_classes = ['navbar', 'navbar-expand-lg', 'mb-4'];
if (in_array('dark-background-active', $body_classes) || $theme === 'dark') {
$navbar_classes[] = 'navbar-dark';
} else {
$navbar_classes[] = 'navbar-light';
}
$data_bs_theme = 'light';
if ($theme === 'dark' || in_array('dark-background-active', $body_classes)) {
$data_bs_theme = 'dark';
}
?>
<!DOCTYPE html>
<html lang="<?php echo htmlspecialchars($language); ?>" data-bs-theme="<?php echo $data_bs_theme; ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo htmlspecialchars($settings['site_title'] ?? 'Bookmarkly'); ?></title>
<link rel="icon" href="<?php echo htmlspecialchars($settings['site_favicon'] ?? $settings['site_logo'] ?? 'include/images/favicon.ico'); ?>">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
<link href="include/css/style.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<style>
:root {
--bg-brightness: <?php echo intval($bg_brightness); ?>%;
--bg-saturation: <?php echo intval($bg_saturation); ?>%;
--theme-transparency: <?php echo intval($theme_transparency) / 100; ?>;
}
body {
<?php if (!empty($bg_url)): ?>
background-image: url('<?php echo htmlspecialchars($bg_url); ?>');
background-size: cover;
background-position: center;
background-attachment: fixed;
<?php endif; ?>
}
body::before {
content: '';
position: fixed;
top: 0; left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
<?php if (!empty($bg_url)): ?>
backdrop-filter: brightness(var(--bg-brightness)) saturate(var(--bg-saturation));
<?php else: ?>
/* Apply a default background color if no image is set */
background-color: var(--bs-body-bg);
<?php endif; ?>
}
<?php if ($theme === 'transparent'): ?>
.card, .modal-content {
background-color: rgba(var(--bs-body-bg-rgb), var(--theme-transparency)) !important;
backdrop-filter: blur(10px);
}
<?php endif; ?>
<?php echo $custom_css; /* User-provided CSS, ensure sanitization if necessary elsewhere */ ?>
</style>
</head>
<body class="<?php echo implode(' ', $body_classes); ?>" data-favicon-service-url="<?php echo htmlspecialchars($settings['favicon_extractor_url']); ?>">
<header>
<nav class="navbar navbar-expand-lg">
<div class="container-fluid">
<a class="navbar-brand" href="index.php">
<img src="<?php echo htmlspecialchars($settings['site_logo'] ?? 'include/images/favicon.ico'); ?>" alt="<?php echo htmlspecialchars($settings['site_title'] ?? 'Bookmarkly'); ?> Logo" height="<?php echo htmlspecialchars($settings['logo_height'] ?? '30'); ?>">
<?php if (($settings['display_site_title'] ?? '1') === '1'): ?>
<?php echo htmlspecialchars($settings['site_title'] ?? 'Bookmarkly'); ?>
<?php endif; ?>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<?php if (isset($_SESSION['user_id'])): ?>
<?php if (isAdmin() || isUser()): ?>
<li class="nav-item">
<a class="nav-link" href="bookmarks.php"><i class="bi bi-bookmarks-fill"></i> <?php echo translate('nav_manage'); ?></a>
</li>
<?php endif; ?>
<?php if (isAdmin()): ?>
<li class="nav-item">
<a class="nav-link" href="tabs_settings.php"><i class="bi bi-folder"></i> <?php echo translate('nav_manage_tabs'); ?></a>
</li>
<?php endif; ?>
<li class="nav-item">
<a class="nav-link" href="settings.php"><i class="bi bi-gear-fill"></i> <?php echo translate('nav_settings'); ?></a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle"></i> <?php echo htmlspecialchars($_SESSION['username']); ?>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="profile.php">Change Password</a></li>
<?php if (isAdmin()): ?>
<li><a class="dropdown-item" href="user_settings.php">Manage Users</a></li>
<?php endif; ?>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="logout.php"><i class="bi bi-box-arrow-right"></i> <?php echo translate('nav_logout'); ?></a></li>
</ul>
</li>
<?php else: ?>
<li class="nav-item">
<a class="nav-link" href="login.php"><i class="bi bi-box-arrow-in-right"></i> <?php echo translate('login_button'); ?></a>
</li>
<?php endif; ?>
</ul>
</div>
</div>
</nav>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+229
View File
@@ -0,0 +1,229 @@
<?php
// Database configuration
define('DB_PATH', __DIR__ . '/../data/database/bookmarkly.db');
// Create a new database connection
try {
$pdo = new PDO('sqlite:' . DB_PATH);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
// Load language files, which now uses the $pdo object from this script
require_once __DIR__ . '/language.php';
// Start session is now in language.php
// session_start();
// Error reporting for development is now handled in language.php based on settings
/*
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
*/
// Function to check if a table exists
function tableExists($pdo, $tableName) {
try {
$result = $pdo->query("SELECT 1 FROM {$tableName} LIMIT 1");
} catch (Exception $e) {
return false;
}
return $result !== false;
}
// Function to check if a column exists in a table
function columnExists($pdo, $tableName, $columnName) {
try {
$result = $pdo->query("SELECT {$columnName} FROM {$tableName} LIMIT 1");
} catch (Exception $e) {
return false;
}
return $result !== false;
}
// Initialize database schema if tables don't exist
$db_initialized = tableExists($pdo, 'users');
if ($db_initialized) {
// For existing installations, run migration checks
if (!columnExists($pdo, 'users', 'role')) {
$pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
$pdo->exec("UPDATE users SET role = 'admin' WHERE id = 1");
}
}
if (!$db_initialized) {
// Create users table
$pdo->exec("
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
)
");
// Insert a default user (e.g., admin/password)
$default_password = password_hash('password', PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, password, role) VALUES (?, ?, ?)");
$stmt->execute(['admin', $default_password, 'admin']);
// Create settings table
$pdo->exec("
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
)
");
// Insert default settings
$default_settings = [
'dashboard_protected' => '0',
'remember_me_duration' => '2 weeks',
'open_new_tab' => '1',
'language' => 'en',
'favicon_extractor_url' => 'https://favicon.hibbit.cloud',
'debug_mode' => '0',
'theme' => 'transparent',
'background_image_url' => '',
'background_brightness' => '50',
'background_saturation' => '100',
'pexels_api_key' => 'VaNzz6B9q31971WDf1h4TjwEvgib6XvXQp58AhJnX7dGqDawavi7SQAd',
'custom_css' => '',
'theme_transparency' => '40'
];
$stmt = $pdo->prepare("INSERT INTO settings (key, value) VALUES (?, ?)");
foreach ($default_settings as $key => $value) {
$stmt->execute([$key, $value]);
}
// Create categories table
$pdo->exec("
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
sort_order INTEGER
)
");
// Create bookmarks table
$pdo->exec("
CREATE TABLE bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER,
title TEXT NOT NULL,
url TEXT NOT NULL,
icon TEXT,
is_favorite INTEGER DEFAULT 0,
sort_order INTEGER,
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
)
");
}
// Check if tabs table exists
if (!tableExists($pdo, 'tabs')) {
$pdo->exec("
CREATE TABLE tabs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
user_id INTEGER,
sort_order INTEGER,
background_image_url TEXT,
background_brightness INTEGER DEFAULT 100,
background_saturation INTEGER DEFAULT 100,
theme TEXT DEFAULT 'dark',
theme_transparency INTEGER DEFAULT 85,
login_token VARCHAR(255) UNIQUE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
)
");
// Insert a default tab
$default_token = bin2hex(random_bytes(16));
$pdo->prepare("INSERT INTO tabs (name, sort_order, login_token) VALUES (?, ?, ?)")
->execute(['Default', 1, $default_token]);
}
// Migration: Add sort_order to tabs table if it doesn't exist
if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'sort_order')) {
$pdo->exec("ALTER TABLE tabs ADD COLUMN sort_order INTEGER");
// Initialize sort order based on existing IDs
$pdo->exec("UPDATE tabs SET sort_order = id");
}
// Migration: Add new columns to tabs table if they don't exist
if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'background_image_url')) {
$pdo->exec("ALTER TABLE tabs ADD COLUMN background_image_url TEXT");
}
if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'background_brightness')) {
$pdo->exec("ALTER TABLE tabs ADD COLUMN background_brightness INTEGER DEFAULT 100");
}
if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'background_saturation')) {
$pdo->exec("ALTER TABLE tabs ADD COLUMN background_saturation INTEGER DEFAULT 100");
}
if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'theme')) {
$pdo->exec("ALTER TABLE tabs ADD COLUMN theme TEXT DEFAULT 'dark'");
}
if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'theme_transparency')) {
$pdo->exec("ALTER TABLE tabs ADD COLUMN theme_transparency INTEGER DEFAULT 85");
}
// Migration: Add column_count to tabs table if it doesn't exist
if (tableExists($pdo, 'tabs') && !columnExists($pdo, 'tabs', 'column_count')) {
$pdo->exec("ALTER TABLE tabs ADD COLUMN column_count INTEGER DEFAULT 5");
}
// Check if tab_id column exists in bookmarks table
if (!columnExists($pdo, 'bookmarks', 'tab_id')) {
$pdo->exec("ALTER TABLE bookmarks ADD COLUMN tab_id INTEGER");
// Set default tab_id for existing bookmarks
$pdo->exec("UPDATE bookmarks SET tab_id = 1");
}
// Check if tab_id column exists in categories table
if (!columnExists($pdo, 'categories', 'tab_id')) {
$pdo->exec("ALTER TABLE categories ADD COLUMN tab_id INTEGER");
// Set default tab_id for existing categories
$pdo->exec("UPDATE categories SET tab_id = 1");
}
// Create tab_viewer_access table
if (!tableExists($pdo, 'tab_viewer_access')) {
$pdo->exec("
CREATE TABLE tab_viewer_access (
user_id INTEGER NOT NULL,
tab_id INTEGER NOT NULL,
PRIMARY KEY (user_id, tab_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (tab_id) REFERENCES tabs (id) ON DELETE CASCADE
)
");
}
// Create user_tab_visibility table
if (!tableExists($pdo, 'user_tab_visibility')) {
$pdo->exec("
CREATE TABLE user_tab_visibility (
user_id INTEGER NOT NULL,
tab_id INTEGER NOT NULL,
PRIMARY KEY (user_id, tab_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (tab_id) REFERENCES tabs (id) ON DELETE CASCADE
)
");
}
// Load all settings into a global variable for easy access
try {
$settings_stmt = $pdo->query("SELECT key, value FROM settings");
$settings = $settings_stmt->fetchAll(PDO::FETCH_KEY_PAIR);
$GLOBALS['settings'] = $settings; // Make it globally available
} catch (PDOException $e) {
// If settings can't be loaded, initialize as empty array to prevent fatal errors
$settings = [];
$GLOBALS['settings'] = [];
}
// Set timezone, default to UTC
date_default_timezone_set($settings['timezone'] ?? 'UTC');
+323
View File
@@ -0,0 +1,323 @@
// JavaScript for Bookmarkly
document.addEventListener('DOMContentLoaded', function() {
// Handle Edit Category Modal
const editCategoryModal = document.getElementById('editCategoryModal');
if (editCategoryModal) {
editCategoryModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const id = button.getAttribute('data-bs-id');
const name = button.getAttribute('data-bs-name');
const idInput = editCategoryModal.querySelector('#edit-category-id');
const nameInput = editCategoryModal.querySelector('#edit-category-name');
idInput.value = id;
nameInput.value = name;
});
}
// --- Bookmark Modals ---
const editBookmarkModal = document.getElementById('editBookmarkModal');
if (editBookmarkModal) {
editBookmarkModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const id = button.getAttribute('data-bs-id');
const title = button.getAttribute('data-bs-title');
const url = button.getAttribute('data-bs-url');
const category = button.getAttribute('data-bs-category');
const icon = button.getAttribute('data-bs-icon');
const iconSrc = button.getAttribute('data-bs-icon-src');
const favorite = button.getAttribute('data-bs-favorite');
const modalTitle = editBookmarkModal.querySelector('.modal-title');
const idInput = editBookmarkModal.querySelector('#edit-bookmark-id');
const titleInput = editBookmarkModal.querySelector('#edit-bookmark-title');
const urlInput = editBookmarkModal.querySelector('#edit-bookmark-url');
const categoryInput = editBookmarkModal.querySelector('#edit-bookmark-category');
const iconUrlInput = editBookmarkModal.querySelector('#edit-bookmark-icon-url');
const favoriteInput = editBookmarkModal.querySelector('#edit-bookmark-favorite');
const currentIconPreview = document.getElementById('current-icon-preview');
const currentIconContainer = document.getElementById('current-icon-container');
const removeCustomIconBtn = document.getElementById('remove-custom-icon');
const removeIconInput = document.getElementById('remove-icon-input');
modalTitle.textContent = 'Edit ' + title;
idInput.value = id;
titleInput.value = title;
urlInput.value = url;
categoryInput.value = category;
favoriteInput.checked = (favorite == '1');
// Handle icon display logic
currentIconContainer.style.display = 'none'; // Hide by default
// Handle text input for icon URL/domain
if (icon && icon.startsWith('data/images/custom/')) {
iconUrlInput.value = ''; // Clear if it's a custom icon
} else {
// For domains or full URLs, just show the value in the input.
iconUrlInput.value = icon;
}
// Handle current icon preview and remove button
if (icon && icon.startsWith('data/images/custom/')) {
currentIconPreview.src = iconSrc;
currentIconContainer.style.display = 'block';
removeIconInput.value = '0';
} else {
currentIconContainer.style.display = 'none';
}
removeCustomIconBtn.onclick = function() {
removeIconInput.value = '1';
currentIconContainer.style.display = 'none';
document.getElementById('edit-bookmark-icon-upload').value = '';
};
});
// Reset remove icon flag when modal is hidden
editBookmarkModal.addEventListener('hidden.bs.modal', function () {
document.getElementById('remove-icon-input').value = '0';
document.getElementById('edit-bookmark-icon-upload').value = ''; // Clear file input
});
}
// --- Favicon Fetching ---
function fetchFavicon(url, targetInputId) {
if (!url) return;
try {
// We only want to store the domain, not the full generated URL
const domain = new URL(url).hostname;
const targetInput = document.getElementById(targetInputId);
if (targetInput) {
targetInput.value = domain;
}
} catch (e) {
console.error('Invalid URL for favicon fetching:', e);
const targetInput = document.getElementById(targetInputId);
if (targetInput) {
targetInput.value = '';
}
}
}
// Event listener for fetching favicon in ADD modal
const fetchButtonAdd = document.getElementById('fetch-favicon-add');
const urlInputAdd = document.getElementById('bookmark-url');
if (fetchButtonAdd && urlInputAdd) {
// Auto-fetch on change
urlInputAdd.addEventListener('change', function() {
fetchFavicon(urlInputAdd.value, 'bookmark-icon-url');
});
// Manual fetch on button click
fetchButtonAdd.addEventListener('click', function() {
fetchFavicon(urlInputAdd.value, 'bookmark-icon-url');
});
}
// Event listener for fetching favicon in EDIT modal
const fetchButtonEdit = document.getElementById('fetch-favicon-edit');
const urlInputEdit = document.getElementById('edit-bookmark-url');
if (fetchButtonEdit && urlInputEdit) {
urlInputEdit.addEventListener('change', function() {
const iconContainer = document.getElementById('current-icon-container');
// Only fetch favicon if there is no custom icon currently set
if (iconContainer && iconContainer.style.display === 'none') {
fetchFavicon(urlInputEdit.value, 'edit-bookmark-icon-url');
}
});
fetchButtonEdit.addEventListener('click', function() {
fetchFavicon(urlInputEdit.value, 'edit-bookmark-icon-url');
});
}
// --- Pexels API ---
const pexelsSearchButton = document.getElementById('pexels_search_button');
const pexelsSearchQuery = document.getElementById('pexels_search_query');
const pexelsResultsContainer = document.getElementById('pexels_results');
const bgUrlInput = document.getElementById('background_image_url');
if (pexelsSearchButton && pexelsSearchQuery && pexelsResultsContainer && bgUrlInput) {
const performSearch = () => {
const query = pexelsSearchQuery.value;
if (!query) {
alert('Please enter a search term.');
return;
}
pexelsResultsContainer.innerHTML = '<div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div>';
fetch(`api/pexels.php?query=${encodeURIComponent(query)}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
pexelsResultsContainer.innerHTML = '';
if (data.error) {
pexelsResultsContainer.innerHTML = `<div class="col-12"><div class="alert alert-danger">${data.error}</div></div>`;
return;
}
if (data.photos && data.photos.length > 0) {
data.photos.forEach(photo => {
const imgContainer = document.createElement('div');
imgContainer.className = 'col';
const img = document.createElement('img');
img.src = photo.src.medium;
img.className = 'img-thumbnail';
img.style.cursor = 'pointer';
img.alt = photo.alt;
img.addEventListener('click', () => {
bgUrlInput.value = photo.src.original;
// Optionally, show a preview
document.body.style.backgroundImage = `url(${photo.src.original})`;
pexelsResultsContainer.innerHTML = '<div class="col-12"><div class="alert alert-success">Background selected! Save theme settings to apply.</div></div>';
});
imgContainer.appendChild(img);
pexelsResultsContainer.appendChild(imgContainer);
});
} else {
pexelsResultsContainer.innerHTML = '<div class="col-12"><div class="alert alert-warning">No images found for your query.</div></div>';
}
})
.catch(error => {
console.error('Error fetching from Pexels API:', error);
pexelsResultsContainer.innerHTML = '<div class="col-12"><div class="alert alert-danger">An error occurred while searching.</div></div>';
});
};
pexelsSearchButton.addEventListener('click', performSearch);
pexelsSearchQuery.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
}
// Live preview for background settings
const brightnessSlider = document.getElementById('background_brightness');
const saturationSlider = document.getElementById('background_saturation');
const transparencySlider = document.getElementById('theme_transparency');
const themeSelector = document.getElementById('theme');
const transparencyContainer = document.getElementById('transparency-slider-container');
const updateTextColor = () => {
if (themeSelector && themeSelector.value !== 'dark' && brightnessSlider && brightnessSlider.value < 70) {
document.body.classList.add('dark-background-active');
} else {
document.body.classList.remove('dark-background-active');
}
};
const toggleTransparencySlider = () => {
if (themeSelector && transparencyContainer) {
transparencyContainer.style.display = themeSelector.value === 'transparent' ? 'block' : 'none';
}
};
// Initial check
toggleTransparencySlider();
if(themeSelector) {
themeSelector.addEventListener('change', () => {
toggleTransparencySlider();
updateTextColor();
});
}
if (brightnessSlider) {
brightnessSlider.addEventListener('input', (event) => {
document.documentElement.style.setProperty('--bg-brightness', `${event.target.value}%`);
updateTextColor();
});
}
if (saturationSlider) {
saturationSlider.addEventListener('input', (event) => {
document.documentElement.style.setProperty('--bg-saturation', `${event.target.value}%`);
});
}
if (transparencySlider) {
transparencySlider.addEventListener('input', (event) => {
document.documentElement.style.setProperty('--theme-transparency', event.target.value / 100);
});
}
// Initialize SortableJS for drag-and-drop
initSortable();
});
function initSortable() {
const categoryList = document.getElementById('category-list');
if (categoryList) {
new Sortable(categoryList, {
handle: '.sort-handle',
animation: 150,
onEnd: function (evt) {
const order = Array.from(evt.target.children).map(el => el.getAttribute('data-id'));
updateSortOrder('category', order);
}
});
}
const bookmarkLists = document.querySelectorAll('.bookmark-list');
bookmarkLists.forEach(list => {
new Sortable(list, {
handle: '.sort-handle',
animation: 150,
onEnd: function (evt) {
const order = Array.from(evt.target.children).map(el => el.getAttribute('data-id'));
updateSortOrder('bookmark', order);
}
});
});
}
function updateSortOrder(type, order) {
fetch('api/update_order.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ type: type, order: order })
})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.error('Failed to update sort order:', data.error);
// Optionally, show an error message to the user
}
})
.catch(error => console.error('Error updating sort order:', error));
}
function optimizeImage(path) {
if (confirm('Are you sure you want to optimize this image? The original file will be overwritten.')) {
const formData = new FormData();
formData.append('path', path);
fetch('api/optimize_image.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Image optimized successfully. The page will now reload.');
window.location.reload();
} else {
alert('An error occurred: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('A network error occurred. Please try again.');
});
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
// include/language.php
session_start();
// The $pdo object is available from init.php which included this file.
// Get language setting from database
try {
$stmt = $pdo->query("SELECT value FROM settings WHERE key = 'language'");
$language = $stmt->fetchColumn();
// Also fetch debug mode setting here
$stmt = $pdo->query("SELECT value FROM settings WHERE key = 'debug_mode'");
$debug_mode = $stmt->fetchColumn();
} catch (Exception $e) {
// If database/table doesn't exist yet, default to English and debug off
$language = 'en';
$debug_mode = '0';
}
$language = $language ?: 'en';
$debug_mode = $debug_mode ?: '0';
// Set error reporting based on debug mode
if ($debug_mode === '1') {
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
} else {
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
error_reporting(0);
}
// Load the language file
$lang_file = __DIR__ . "/../lang/{$language}.php";
if (file_exists($lang_file)) {
$translations = require $lang_file;
} else {
// Fallback to English if the language file doesn't exist
$translations = require __DIR__ . '/../lang/en.php';
}
// Function to get a translated string
function translate($key) {
global $translations;
return $translations[$key] ?? $key;
}
+270
View File
@@ -0,0 +1,270 @@
<?php
$page_title = 'Bookmarkly';
require_once 'include/init.php'; // Use init.php first for DB connection
// Handle token login BEFORE auth check
if (isset($_GET['token'])) {
$token = $_GET['token'];
$stmt = $pdo->prepare("SELECT id FROM tabs WHERE login_token = ?");
$stmt->execute([$token]);
$tab = $stmt->fetch();
if ($tab) {
// Log in as a temporary guest for this tab
$_SESSION['guest_tab_id'] = $tab['id'];
// Redirect to the tab view without the token in the URL
header('Location: index.php?tab=' . $tab['id']);
exit;
} else {
// Invalid token, redirect to login or show an error
header('Location: login.php?error=invalid_token');
exit;
}
}
require_once 'include/auth.php';
// Fetch all settings for theme and layout
$stmt = $pdo->query("SELECT key, value FROM settings");
$settings = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
$open_in_new_tab = $settings['open_new_tab'] ?? '1';
$target_attr = $open_in_new_tab === '1' ? 'target="_blank"' : '';
// Get current user ID if logged in
$current_user_id = $_SESSION['user_id'] ?? null;
$current_user_role = $_SESSION['role'] ?? null;
$guest_tab_id = $_SESSION['guest_tab_id'] ?? null;
// Base query for fetching tabs
$tabs_stmt_sql = "SELECT DISTINCT t.* FROM tabs t ";
$params = [];
// Logic to determine which tabs are visible based on role and explicit permissions
$visibility_conditions = "
LEFT JOIN user_tab_visibility utv ON t.id = utv.tab_id AND utv.user_id = :current_user_id
WHERE utv.user_id IS NULL
";
if ($guest_tab_id) {
// Guest user with token can only see the specific tab
$tabs_stmt_sql .= "WHERE t.id = :guest_tab_id";
$params[':guest_tab_id'] = $guest_tab_id;
} else if ($current_user_id) {
if ($current_user_role === 'admin') {
// Admin sees all tabs that are not explicitly hidden for them
$tabs_stmt_sql .= $visibility_conditions;
} else { // 'user' and 'viewer' roles
// Users/Viewers see public tabs, their own tabs, or tabs they have access to,
// as long as they are not explicitly hidden.
$tabs_stmt_sql .= "
LEFT JOIN tab_viewer_access tva ON t.id = tva.tab_id
{$visibility_conditions}
AND (t.user_id IS NULL OR t.user_id = :current_user_id OR tva.user_id = :current_user_id)
";
}
$params[':current_user_id'] = $current_user_id;
} else {
// Guest sees only public (ownerless) tabs
$tabs_stmt_sql .= "WHERE t.user_id IS NULL";
}
$tabs_stmt_sql .= " ORDER BY t.sort_order, t.id";
$tabs_stmt = $pdo->prepare($tabs_stmt_sql);
$tabs_stmt->execute($params);
$tabs = $tabs_stmt->fetchAll();
// Determine the default tab to show.
// If the requested tab is not in the visible list, fall back to the first visible one.
$visible_tab_ids = array_column($tabs, 'id');
$current_tab_id = isset($_GET['tab']) ? (int)$_GET['tab'] : ($visible_tab_ids[0] ?? 1);
if (!in_array($current_tab_id, $visible_tab_ids)) {
$current_tab_id = $visible_tab_ids[0] ?? 1;
}
// Fetch details for the current tab to get background info
$current_tab_data = null;
if ($current_tab_id) {
$tab_details_stmt = $pdo->prepare("SELECT * FROM tabs WHERE id = :id");
$tab_details_stmt->execute([':id' => $current_tab_id]);
$current_tab_data = $tab_details_stmt->fetch();
}
// Get the ID of the 'FAVORITES' category (case-insensitive)
$fav_cat_stmt = $pdo->prepare("SELECT id FROM categories WHERE UPPER(name) = 'FAVORITES'");
$fav_cat_stmt->execute();
$favorites_cat_id = $fav_cat_stmt->fetchColumn();
// Fetch favorite bookmarks from the 'FAVORITES' category for the current tab
$fav_stmt = $pdo->prepare("
SELECT b.*
FROM bookmarks b
JOIN categories c ON b.category_id = c.id
WHERE b.is_favorite = 1 AND c.tab_id = :tab_id
ORDER BY b.sort_order ASC, b.title ASC
");
$fav_stmt->execute([':tab_id' => $current_tab_id]);
$favorites = $fav_stmt->fetchAll();
// Fetch categories for the current tab, excluding 'FAVORITES'
$cat_stmt = $pdo->prepare("SELECT * FROM categories WHERE tab_id = :tab_id AND UPPER(name) != 'FAVORITES' ORDER BY sort_order ASC, name ASC");
$cat_stmt->execute([':tab_id' => $current_tab_id]);
$categories = $cat_stmt->fetchAll();
// Fetch bookmarks for each category
$bookmarks_by_cat = [];
if ($categories) {
$bookmarks_stmt = $pdo->prepare("SELECT * FROM bookmarks WHERE category_id = :cat_id AND (is_favorite IS NULL OR is_favorite = 0) ORDER BY sort_order ASC, title ASC");
foreach ($categories as $category) {
$bookmarks_stmt->execute([':cat_id' => $category['id']]);
$bookmarks_by_cat[$category['id']] = $bookmarks_stmt->fetchAll();
}
}
require_once 'include/header.php';
?>
<div class="container mt-4">
<?php if (count($tabs) > 1): ?>
<ul class="nav nav-tabs" id="tabs-container">
<?php foreach ($tabs as $tab): ?>
<li class="nav-item" role="presentation" data-tab-id="<?php echo $tab['id']; ?>">
<a class="nav-link <?php if ($tab['id'] == $current_tab_id) echo 'active'; ?>" href="?tab=<?php echo $tab['id']; ?>">
<?php echo htmlspecialchars($tab['name'] === 'Default' ? translate('tab_default_name') : $tab['name']); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="tab-content mt-3">
<!-- Favorites -->
<?php if (!empty($favorites)): ?>
<div class="category-column mb-5">
<h5 class="mb-3"><?php echo translate('index_favorites'); ?></h5>
<div class="row">
<?php foreach ($favorites as $bookmark): ?>
<?php
// Clean up URL for display
$display_url = preg_replace('/^https?:\/\/(www\.)?/', '', $bookmark['url']);
$display_url = rtrim($display_url, '/');
// Build icon source URL
$icon_src = 'include/images/default-icon.png'; // Default icon
if (!empty($bookmark['icon'])) {
if (file_exists($bookmark['icon']) && strpos($bookmark['icon'], 'data/images/custom/') === 0) {
$icon_src = htmlspecialchars($bookmark['icon']);
} elseif (filter_var($bookmark['icon'], FILTER_VALIDATE_URL)) {
$icon_src = htmlspecialchars($bookmark['icon']);
} elseif (!empty($settings['favicon_extractor_url'])) {
$base_url = rtrim($settings['favicon_extractor_url'], '/');
$domain = htmlspecialchars($bookmark['icon']);
$icon_src = "{$base_url}/favicon/{$domain}?larger=true";
}
}
?>
<div class="col-lg-2 col-md-3 col-sm-4 col-6 mb-3">
<a href="<?php echo htmlspecialchars($bookmark['url']); ?>" <?php echo $target_attr; ?> class="btn d-flex align-items-center text-start p-2 bookmark-link">
<img src="<?php echo $icon_src; ?>" alt="" class="bookmark-favorite-icon me-3" onerror="this.onerror=null;this.src='include/images/default-icon.png';">
<div>
<span><?php echo htmlspecialchars($bookmark['title']); ?></span>
<small class="d-block text-muted"><?php echo htmlspecialchars($display_url); ?></small>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Categories and Bookmarks -->
<?php
$column_count = $current_tab_data['column_count'] ?? 5;
?>
<div class="row g-3 row-cols-1 row-cols-md-<?php echo htmlspecialchars($column_count); ?>">
<?php foreach ($categories as $category): ?>
<?php
$bookmarks = $bookmarks_by_cat[$category['id']] ?? [];
if (empty($bookmarks)) continue;
?>
<div class="col">
<div class="category-column">
<h5 class="mb-3"><?php echo htmlspecialchars($category['name']); ?></h5>
<div class="list-group list-group-flush">
<?php foreach ($bookmarks as $bookmark): ?>
<?php
// Build icon source URL
$icon_src = 'include/images/default-icon.png'; // Default icon
if (!empty($bookmark['icon'])) {
if (file_exists($bookmark['icon']) && strpos($bookmark['icon'], 'data/images/custom/') === 0) {
$icon_src = htmlspecialchars($bookmark['icon']);
} elseif (filter_var($bookmark['icon'], FILTER_VALIDATE_URL)) {
$icon_src = htmlspecialchars($bookmark['icon']);
} elseif (!empty($settings['favicon_extractor_url'])) {
$base_url = rtrim($settings['favicon_extractor_url'], '/');
$domain = htmlspecialchars($bookmark['icon']);
$icon_src = "{$base_url}/favicon/{$domain}?larger=true";
}
}
?>
<a href="<?php echo htmlspecialchars($bookmark['url']); ?>" <?php echo $target_attr; ?> class="list-group-item list-group-item-action d-flex align-items-center">
<img src="<?php echo $icon_src; ?>" alt="" class="bookmark-icon me-2" onerror="this.onerror=null;this.src='include/images/default-icon.png';">
<span><?php echo htmlspecialchars($bookmark['title']); ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (empty($favorites) && empty($categories)): ?>
<div class="text-center mt-5">
<h3><?php echo translate('index_welcome_title'); ?></h3>
<p class="lead"><?php echo translate('index_welcome_text'); ?></p>
<a href="bookmarks.php" class="btn btn-primary"><?php echo translate('index_welcome_button'); ?></a>
</div>
<?php endif; ?>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const tabsContainer = document.getElementById('tabs-container');
if (tabsContainer) {
new Sortable(tabsContainer, {
animation: 150,
onUpdate: function (evt) {
const tabIds = Array.from(tabsContainer.children).map(child => child.dataset.tabId);
// Send the new order to the server
fetch('ajax_update_tab_order.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tab_ids: tabIds }),
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Optionally, show a success message
console.log('Tab order saved!');
} else {
// Handle error
console.error('Failed to save tab order.');
}
})
.catch((error) => {
console.error('Error:', error);
});
}
});
}
});
</script>
<?php require_once 'include/footer.php'; ?>
+148
View File
@@ -0,0 +1,148 @@
<?php
// lang/en.php
return [
'app_name' => 'Bookmarkly',
'header_settings' => 'Settings',
'header_manage_bookmarks' => 'Manage Bookmarks',
'header_logout' => 'Logout',
// Nav specific translations
'nav_settings' => 'Settings',
'nav_manage' => 'Bookmarks',
'nav_manage_tabs' => 'Manage Tabs',
'nav_logout' => 'Logout',
// Login page
'login_title' => 'Login',
'username' => 'Username',
'password' => 'Password',
'login_button' => 'Login',
'error_invalid_credentials' => 'Invalid username or password.',
'error_fill_fields' => 'Please fill in both fields.',
'index_search_placeholder' => 'Search (Ctrl+K)',
'index_favorites' => 'FAVORITES',
'index_welcome_title' => 'Welcome to Bookmarkly!',
'index_welcome_text' => 'You don\'t have any bookmarks yet.<br><br>Login: <b>admin</b> / <b>password</b> (change it after login) ↗️<br><br>',
'index_welcome_button' => '👉 Add your first bookmark',
'bookmarks_manage_title' => 'Manage Bookmarks',
'bookmarks_add_bookmark_button' => 'Add Bookmark',
'bookmarks_categories' => 'Categories',
'bookmarks_new_category_placeholder' => 'New category name',
'bookmarks_add_category_button' => 'Add',
'bookmarks_bookmarks' => 'Bookmarks',
'bookmarks_category_empty' => 'No bookmarks in this category yet.',
'bookmarks_no_category_text' => 'You need to create a category first before you can add bookmarks.',
'bookmarks_delete_category_confirm' => 'Are you sure you want to delete this category and all its bookmarks?',
'bookmarks_delete_bookmark_confirm' => 'Are you sure you want to delete this bookmark?',
'modal_add_bookmark_title' => 'Add New Bookmark',
'modal_edit_bookmark_title' => 'Edit Bookmark',
'modal_edit_category_title' => 'Edit Category',
'modal_category_name' => 'Category Name',
'modal_title' => 'Title',
'modal_url' => 'URL',
'modal_category' => 'Category',
'modal_select_category' => 'Select a category...',
'modal_icon_url' => 'Icon URL (optional)',
'modal_icon_url_help' => 'Find icons at <a href="https://selfh.st/icons/" target="_blank">selfh.st/icons</a> and <a href="https://dashboardicons.com/icons" target="_blank">dashboardicons.com/icons</a>.',
'modal_icon_upload' => 'Upload Icon (optional)',
'modal_current_icon' => 'Current Icon',
'modal_remove_icon_button' => 'Remove Custom Icon',
'modal_fetch_favicon_button' => 'Fetch Favicon from URL',
'modal_mark_as_favorite' => 'Mark as Favorite',
'modal_close_button' => 'Close',
'modal_save_bookmark_button' => 'Save Bookmark',
'modal_save_changes_button' => 'Save Changes',
'settings_title' => 'Settings',
'settings_general_tab' => 'General',
'settings_theme_tab' => 'Theme & Background',
'settings_backup_tab' => 'Backup & Restore',
'settings_save_success' => 'Settings saved successfully.',
'settings_general_title' => 'General Settings',
'settings_open_new_tab' => 'Open bookmarks in a new tab',
'settings_dashboard_protection' => 'Protect dashboard with password (requires re-login)',
'settings_stay_logged_in' => '"Stay logged in" duration',
'settings_language' => 'Language',
'settings_favicon_extractor_url' => 'Favicon Extractor Base URL',
'settings_favicon_extractor_url_help' => 'Provide the service base URL (e.g., http://localhost:3000). The path /favicon/{URL}?larger=true will be appended automatically.',
'settings_debug_mode' => 'Enable Debug mode',
'settings_save_settings_button' => 'Save Settings',
'settings_user_account_title' => 'User Account',
'settings_new_password' => 'New Password (leave blank to keep current)',
'settings_confirm_password' => 'Confirm New Password',
'settings_update_profile_button' => 'Update Profile',
'settings_user_update_success' => 'User profile updated.',
'settings_password_mismatch' => 'New passwords do not match.',
'settings_theme_title' => 'Theme & Background',
'settings_theme' => 'Theme',
'settings_theme_light' => 'Light',
'settings_theme_dark' => 'Dark',
'settings_theme_transparent' => 'Transparent',
'settings_theme_transparency' => 'Theme Transparency',
'settings_background_url' => 'Background Image URL',
'settings_background_upload' => 'Upload Background',
'settings_background_current' => 'Current background:',
'settings_background_optimize' => 'Optimize',
'settings_background_brightness' => 'Background Brightness',
'settings_background_saturation' => 'Background Saturation',
'settings_pexels_integration' => 'Pexels Integration',
'settings_pexels_api_key' => 'Pexels API Key',
'settings_pexels_search_placeholder' => 'Search for a background on Pexels...',
'settings_pexels_search_button' => 'Search',
'settings_custom_css' => 'Custom CSS',
'settings_custom_footer' => 'Custom Footer HTML',
'settings_save_theme_button' => 'Save Theme Settings',
'settings_backup_title' => 'Backup & Restore',
'settings_backup_text' => 'Create a backup of your data, including the database and custom images. You can restore your instance from a backup file.',
'settings_backup_create_button' => 'Create Backup',
'settings_backup_restore_title' => 'Restore from Backup',
'settings_backup_restore_text' => 'Upload a backup file to restore your Bookmarkly instance. This will overwrite current data.',
'settings_backup_restore_input' => 'Backup File (.zip)',
'settings_backup_restore_button' => 'Restore Backup',
'settings_backup_restore_success' => 'Backup restored successfully. Please log in again.',
'settings_backup_restore_failed' => 'Failed to restore backup. Please ensure it is a valid Bookmarkly backup file.',
'settings_backup_create_title' => 'Create Backup',
'settings_backup_full' => 'Entire Site (Full Backup)',
'settings_backup_tab_prefix' => 'Tab',
// System Maintenance
'settings_maintenance_fix_orphaned_bookmarks_title' => 'Fix Orphaned Bookmarks',
'settings_maintenance_fix_orphaned_bookmarks_desc' => 'Finds any bookmarks that are not assigned to a tab and assigns them to the default public tab.',
'settings_maintenance_fix_orphaned_bookmarks_button' => 'Fix Orphaned Bookmarks',
// New strings for import feature
'settings_import_title' => 'Import Bookmarks into a Specific Tab',
'settings_import_text' => 'Import bookmarks from a Bookmarkly backup file into a tab of your choice. This will add the bookmarks and categories from the backup to the selected tab without overwriting existing data.',
'settings_import_input' => 'Backup File (.zip)',
'settings_import_select_tab' => 'Target Tab',
'settings_import_select_tab_placeholder' => '-- Select a Tab --',
'settings_import_button' => 'Import to Tab',
// User Management
'user_management_title' => 'User Management',
'user_management_username' => 'Username',
'user_settings_role' => 'Role',
'user_settings_roles_title' => 'Role Permissions',
'user_settings_role_admin' => 'Admin',
'user_settings_role_admin_desc' => 'Can manage users, tabs, settings, and all bookmarks/categories.',
'user_settings_role_user' => 'User',
'user_settings_role_user_desc' => 'Can manage bookmarks and categories on tabs they own. Can be granted read-only access to other tabs.',
'user_settings_role_viewer' => 'Viewer',
'user_settings_role_viewer_desc' => 'Can only view public tabs and specific tabs they have been granted access to. Cannot edit anything.',
// Tabs
'tab_default_name' => 'Public',
'tab_unique_url_label' => 'Unique Login URL',
'tab_generate_token_button' => 'Generate Token',
'tab_generate_token_confirm' => 'Are you sure you want to generate a new login token? Any existing token will be invalidated.',
'tab_clear_token_button' => 'Clear Token',
'tab_clear_token_confirm' => 'Are you sure you want to clear the login token? The unique URL will no longer work.',
'tooltip_copy_to_clipboard' => 'Copy to clipboard',
'tooltip_copied' => 'Copied!',
];
+149
View File
@@ -0,0 +1,149 @@
<?php
// lang/nl.php
return [
'app_name' => 'Bookmarkly',
'header_settings' => 'Instellingen',
'header_manage_bookmarks' => 'Beheer Bladwijzers',
'header_logout' => 'Uitloggen',
// Nav specific translations
'nav_settings' => 'Instellingen',
'nav_manage' => 'Bladwijzers',
'nav_manage_tabs' => 'Beheer Tabs',
'nav_logout' => 'Uitloggen',
// Login page
'login_title' => 'Inloggen',
'username' => 'Gebruikersnaam',
'password' => 'Wachtwoord',
'login_button' => 'Inloggen',
'error_invalid_credentials' => 'Ongeldige gebruikersnaam of wachtwoord.',
'error_fill_fields' => 'Vul alstublieft beide velden in.',
'index_search_placeholder' => 'Zoeken (Ctrl+K)',
'index_favorites' => 'Favorieten',
'index_welcome_title' => 'Welkom bij Bookmarkly!<br><br>Login: <b>admin</b> / <b>password</b> (wijzig login na inloggen!) ↗️<br>',
'index_welcome_text' => 'Je hebt nog geen bladwijzers.',
'index_welcome_button' => '👉 Voeg je eerste bladwijzer toe',
'bookmarks_manage_title' => 'Bladwijzers Beheren',
'bookmarks_add_bookmark_button' => 'Bladwijzer Toevoegen',
'bookmarks_categories' => 'Categorieën',
'bookmarks_new_category_placeholder' => 'Nieuwe categorienaam',
'bookmarks_add_category_button' => 'Toevoegen',
'bookmarks_bookmarks' => 'Bladwijzers',
'bookmarks_category_empty' => 'Nog geen bladwijzers in deze categorie.',
'bookmarks_no_category_text' => 'Je moet eerst een categorie aanmaken voordat je bladwijzers kunt toevoegen.',
'bookmarks_delete_category_confirm' => 'Weet je zeker dat je deze categorie en alle bijbehorende bladwijzers wilt verwijderen?',
'bookmarks_delete_bookmark_confirm' => 'Weet je zeker dat je deze bladwijzer wilt verwijderen?',
'modal_add_bookmark_title' => 'Nieuwe Bladwijzer Toevoegen',
'modal_edit_bookmark_title' => 'Bladwijzer Bewerken',
'modal_edit_category_title' => 'Categorie Bewerken',
'modal_category_name' => 'Categorienaam',
'modal_title' => 'Titel',
'modal_url' => 'URL',
'modal_category' => 'Categorie',
'modal_select_category' => 'Selecteer een categorie...',
'modal_icon_url' => 'Icoon URL (optioneel)',
'modal_icon_url_help' => 'Vind iconen op <a href="https://selfh.st/icons/" target="_blank">selfh.st/icons</a> en <a href="https://dashboardicons.com/icons" target="_blank">dashboardicons.com/icons</a>.',
'modal_icon_upload' => 'Icoon Uploaden (optioneel)',
'modal_current_icon' => 'Huidig Icoon',
'modal_remove_icon_button' => 'Verwijder Aangepast Icoon',
'modal_fetch_favicon_button' => 'Favicon ophalen van URL',
'modal_mark_as_favorite' => 'Markeer als favoriet',
'modal_close_button' => 'Sluiten',
'modal_save_bookmark_button' => 'Bladwijzer Opslaan',
'modal_save_changes_button' => 'Wijzigingen Opslaan',
'settings_title' => 'Instellingen',
'settings_general_tab' => 'Algemeen',
'settings_theme_tab' => 'Thema & Achtergrond',
'settings_backup_tab' => 'Back-up & Herstel',
'settings_save_success' => 'Instellingen succesvol opgeslagen.',
'settings_general_title' => 'Algemene Instellingen',
'settings_open_new_tab' => 'Bladwijzers in een nieuw tabblad openen',
'settings_dashboard_protection' => 'Dashboard beveiligen met wachtwoord (vereist opnieuw inloggen)',
'settings_stay_logged_in' => 'Duur "Ingelogd blijven"',
'settings_language' => 'Taal',
'settings_favicon_extractor_url' => 'Favicon Extractor Basis URL',
'settings_favicon_extractor_url_help' => 'Geef de basis-URL van de service op (bijv. http://localhost:3000). Het pad /favicon/{URL}?larger=true wordt automatisch toegevoegd.',
'settings_debug_mode' => 'Foutopsporingsmodus inschakelen',
'settings_save_settings_button' => 'Instellingen Opslaan',
'settings_user_account_title' => 'Gebruikersaccount',
'settings_new_password' => 'Nieuw Wachtwoord (laat leeg om huidige te behouden)',
'settings_confirm_password' => 'Bevestig Nieuw Wachtwoord',
'settings_update_profile_button' => 'Profiel Bijwerken',
'settings_user_update_success' => 'Gebruikersprofiel bijgewerkt.',
'settings_password_mismatch' => 'De nieuwe wachtwoorden komen niet overeen.',
'settings_theme_title' => 'Thema & Achtergrond',
'settings_theme' => 'Thema',
'settings_theme_light' => 'Licht',
'settings_theme_dark' => 'Donker',
'settings_theme_transparent' => 'Transparant',
'settings_theme_transparency' => 'Thema Transparantie',
'settings_background_url' => 'Achtergrondafbeelding URL',
'settings_background_upload' => 'Achtergrond Uploaden',
'settings_background_current' => 'Huidige achtergrond:',
'settings_background_optimize' => 'Optimaliseer',
'settings_background_brightness' => 'Helderheid Achtergrond',
'settings_background_saturation' => 'Verzadiging Achtergrond',
'settings_pexels_integration' => 'Pexels Integratie',
'settings_pexels_api_key' => 'Pexels API Sleutel',
'settings_pexels_search_placeholder' => 'Zoek een achtergrond op Pexels...',
'settings_pexels_search_button' => 'Zoeken',
'settings_custom_css' => 'Aangepaste CSS',
'settings_custom_footer' => 'Aangepaste Footer HTML',
'settings_save_theme_button' => 'Thema Instellingen Opslaan',
'settings_backup_title' => 'Back-up & Herstel',
'settings_backup_text' => 'Maak een back-up van je gegevens, inclusief de database en aangepaste afbeeldingen. Je kunt je instantie herstellen vanuit een back-upbestand.',
'settings_backup_create_button' => 'Back-up Maken',
'settings_backup_restore_title' => 'Herstellen van Back-up',
'settings_backup_restore_text' => 'Upload een back-upbestand om je Bookmarkly-instantie te herstellen. Dit overschrijft de huidige gegevens.',
'settings_backup_restore_input' => 'Back-upbestand (.zip)',
'settings_backup_restore_button' => 'Back-up terugzetten',
'settings_backup_restore_success' => 'Back-up succesvol teruggezet. Log opnieuw in.',
'settings_backup_restore_failed' => 'Terugzetten van back-up mislukt. Zorg ervoor dat het een geldig Bookmarkly back-upbestand is.',
'settings_backup_create_title' => 'Back-up Maken',
'settings_backup_full' => 'Volledige Site (Volledige Back-up)',
'settings_backup_tab_prefix' => 'Tabblad',
// System Maintenance
'settings_maintenance_fix_orphaned_bookmarks_title' => 'Losse Bladwijzers Repareren',
'settings_maintenance_fix_orphaned_bookmarks_desc' => 'Zoekt naar bladwijzers die niet aan een tabblad zijn toegewezen en wijst ze toe aan het standaard publieke tabblad.',
'settings_maintenance_fix_orphaned_bookmarks_button' => 'Bladwijzers Repareren',
// Nieuwe strings voor importfunctie
'settings_import_title' => 'Bladwijzers importeren in een specifiek tabblad',
'settings_import_text' => 'Importeer bladwijzers uit een Bookmarkly back-upbestand in een tabblad naar keuze. Dit voegt de bladwijzers en categorieën uit de back-up toe aan het geselecteerde tabblad zonder bestaande gegevens te overschrijven.',
'settings_import_input' => 'Back-upbestand (.zip)',
'settings_import_select_tab' => 'Doeltabblad',
'settings_import_select_tab_placeholder' => '-- Selecteer een tabblad --',
'settings_import_button' => 'Importeren naar tabblad',
// Gebruikersbeheer
'user_management_title' => 'Gebruikersbeheer',
'user_management_username' => 'Gebruikersnaam',
'user_settings_existing_users' => 'Bestaande Gebruikers',
'user_settings_role' => 'Rol',
'user_settings_roles_title' => 'Rol Permissies',
'user_settings_role_admin' => 'Admin',
'user_settings_role_admin_desc' => 'Kan gebruikers, tabbladen, instellingen en alle bladwijzers/categorieën beheren.',
'user_settings_role_user' => 'User',
'user_settings_role_user_desc' => 'Kan bladwijzers en categorieën beheren op tabbladen die eigendom zijn. Kan alleen-lezen toegang krijgen tot andere tabbladen.',
'user_settings_role_viewer' => 'Viewer',
'user_settings_role_viewer_desc' => 'Kan alleen openbare tabbladen bekijken en specifieke tabbladen waartoe toegang is verleend. Kan niets bewerken.',
// Tabs
'tab_default_name' => 'Publiek',
'tab_unique_url_label' => 'Unieke Inlog URL',
'tab_generate_token_button' => 'Token Genereren',
'tab_generate_token_confirm' => 'Weet je zeker dat je een nieuwe inlog-token wilt genereren? Een eventueel bestaande token wordt ongeldig.',
'tab_clear_token_button' => 'Token Wissen',
'tab_clear_token_confirm' => 'Weet je zeker dat je de inlog-token wilt wissen? De unieke URL werkt dan niet meer.',
'tooltip_copy_to_clipboard' => 'Kopieer naar klembord',
'tooltip_copied' => 'Gekopieerd!',
];
+94
View File
@@ -0,0 +1,94 @@
<?php
require_once 'include/init.php';
$error = '';
// Redirect if already logged in
if (isset($_SESSION['user_id'])) {
header('Location: index.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$honeypot = $_POST['website'] ?? '';
// Honeypot check: if the hidden field is filled, it's a bot.
if (!empty($honeypot)) {
// Treat it like a normal failed login to not give away the trick.
$error = translate('error_invalid_credentials');
} elseif (empty($username) || empty($password)) {
$error = translate('error_fill_fields');
} else {
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
// Regenerate session ID to prevent session fixation
session_regenerate_id(true);
// Clear any old session data (like guest_tab_id)
$_SESSION = [];
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
// TODO: Handle "Remember Me"
header('Location: index.php');
exit;
} else {
$error = translate('error_invalid_credentials');
}
}
}
require_once 'include/header.php';
?>
<div class="container">
<div class="row justify-content-center align-items-center" style="min-height: 80vh;">
<div class="col-md-5">
<div class="category-column">
<div class="text-center mb-4">
<?php
$logo_path = $settings['site_logo'] ?? '';
$site_title = htmlspecialchars($settings['site_title'] ?? 'Bookmarkly');
$display_title = ($settings['display_site_title'] ?? '1') === '1';
?>
<?php if (!empty($logo_path)): ?>
<img src="<?php echo htmlspecialchars($logo_path); ?>" alt="<?php echo $site_title; ?> Logo" style="max-height: <?php echo htmlspecialchars($settings['logo_height'] ?? '30'); ?>px;" class="d-inline-block align-middle me-2">
<?php endif; ?>
<?php if ($display_title || empty($logo_path)): ?>
<h3 class="card-title d-inline-block align-middle mb-0"><?php echo $site_title; ?> - login</h3>
<?php endif; ?>
</div>
<?php if ($error): ?>
<div class="alert alert-danger"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<form action="login.php" method="post">
<div class="mb-3">
<label for="username" class="form-label"><?php echo translate('username'); ?></label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label"><?php echo translate('password'); ?></label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3 website-field" style="display: none !important;">
<label for="website" class="form-label">Website</label>
<input type="text" class="form-control" id="website" name="website" autocomplete="off">
</div>
<button type="submit" class="btn btn-primary w-100"><?php echo translate('login_button'); ?></button>
</form>
</div>
</div>
</div>
</div>
<?php require_once 'include/footer.php'; ?>
+27
View File
@@ -0,0 +1,27 @@
<?php
require_once 'include/init.php';
// Clear specific session variables for guest or user logout
if (isset($_SESSION['guest_tab_id'])) {
unset($_SESSION['guest_tab_id']);
}
// Unset all of the session variables for a full user logout.
$_SESSION = [];
// If it's desired to kill the session, also delete the session cookie.
// Note: This will destroy the session, and not just the session data!
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
// Finally, destroy the session.
session_destroy();
// Always redirect to the login page after destroying the session for clear feedback.
header('Location: login.php');
exit;
+89
View File
@@ -0,0 +1,89 @@
<?php
$page_title = 'Change Password - Bookmarkly';
require_once 'include/auth.php'; // Ensures user is logged in
// Flash messages for feedback
$message = '';
$message_type = '';
if (isset($_SESSION['flash_message'])) {
$message = $_SESSION['flash_message']['text'];
$message_type = $_SESSION['flash_message']['type'];
unset($_SESSION['flash_message']);
}
// Handle POST request for changing password
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$current_password = $_POST['current_password'] ?? '';
$new_password = $_POST['new_password'] ?? '';
$confirm_password = $_POST['confirm_password'] ?? '';
$user_id = $_SESSION['user_id'];
// Basic validation
if (empty($current_password) || empty($new_password) || empty($confirm_password)) {
$_SESSION['flash_message'] = ['text' => 'Please fill in all fields.', 'type' => 'warning'];
} elseif ($new_password !== $confirm_password) {
$_SESSION['flash_message'] = ['text' => 'New passwords do not match.', 'type' => 'warning'];
} else {
// Fetch user's current hashed password
$stmt = $pdo->prepare("SELECT password FROM users WHERE id = ?");
$stmt->execute([$user_id]);
$user = $stmt->fetch();
// Verify current password
if ($user && password_verify($current_password, $user['password'])) {
// Hash the new password and update it
$new_hashed_password = password_hash($new_password, PASSWORD_DEFAULT);
$update_stmt = $pdo->prepare("UPDATE users SET password = ? WHERE id = ?");
if ($update_stmt->execute([$new_hashed_password, $user_id])) {
$_SESSION['flash_message'] = ['text' => 'Password changed successfully.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error changing password.', 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Incorrect current password.', 'type' => 'danger'];
}
}
header('Location: profile.php');
exit;
}
require_once 'include/header.php';
?>
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-lg-6">
<h1>Change Password</h1>
<?php if ($message): ?>
<div class="alert alert-<?php echo $message_type; ?> alert-dismissible fade show" role="alert">
<?php echo htmlspecialchars($message); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<div class="card">
<div class="card-body">
<form action="profile.php" method="POST">
<div class="mb-3">
<label for="current_password" class="form-label">Current Password</label>
<input type="password" class="form-control" id="current_password" name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">New Password</label>
<input type="password" class="form-control" id="new_password" name="new_password" required>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Confirm New Password</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary">Update Password</button>
</form>
</div>
</div>
</div>
</div>
</div>
<?php require_once 'include/footer.php'; ?>
+637
View File
@@ -0,0 +1,637 @@
<?php
$page_title = 'Settings - Bookmarkly';
require_once 'include/auth.php';
// Flash messages for feedback
$message = '';
$message_type = '';
if (isset($_SESSION['flash_message'])) {
$message = $_SESSION['flash_message']['text'];
$message_type = $_SESSION['flash_message']['type'];
unset($_SESSION['flash_message']);
}
// Handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'update_settings') {
$upsert_stmt = $pdo->prepare("REPLACE INTO settings (key, value) VALUES (?, ?)");
// Loop through all possible keys from the form to save them
$setting_keys = ['site_title', 'remember_me_duration', 'language', 'favicon_extractor_url', 'logo_height'];
foreach ($setting_keys as $key) {
if (isset($_POST[$key])) {
$upsert_stmt->execute([$key, $_POST[$key]]);
}
}
// Handle checkboxes separately, as they are not sent when unchecked
$checkboxes = ['display_site_title', 'open_new_tab', 'dashboard_protected'];
foreach ($checkboxes as $key) {
$upsert_stmt->execute([$key, isset($_POST[$key]) ? '1' : '0']);
}
// Handle file uploads moved from 'update_theme'
$uploadDir = 'data/images/custom/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$allowed_logo_ext = ['png', 'jpg', 'jpeg', 'gif', 'svg'];
$allowed_favicon_ext = ['png', 'ico', 'svg'];
// Handle Logo Upload
if (isset($_FILES['site_logo']) && $_FILES['site_logo']['error'] === UPLOAD_ERR_OK) {
$logo_ext = strtolower(pathinfo($_FILES['site_logo']['name'], PATHINFO_EXTENSION));
if (in_array($logo_ext, $allowed_logo_ext)) {
// To avoid caching issues, append a timestamp to the filename
$logo_filename = 'logo.' . $logo_ext;
$logo_path = $uploadDir . $logo_filename;
if (move_uploaded_file($_FILES['site_logo']['tmp_name'], $logo_path)) {
$upsert_stmt->execute(['site_logo', $logo_path . '?v=' . time()]);
} else {
$_SESSION['flash_message'] = ['text' => 'Failed to move uploaded logo.', 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Invalid file type for logo.', 'type' => 'danger'];
}
}
// Handle Favicon Upload
if (isset($_FILES['site_favicon']) && $_FILES['site_favicon']['error'] === UPLOAD_ERR_OK) {
$favicon_ext = strtolower(pathinfo($_FILES['site_favicon']['name'], PATHINFO_EXTENSION));
if (in_array($favicon_ext, $allowed_favicon_ext)) {
$favicon_filename = 'favicon.' . $favicon_ext;
$favicon_path = $uploadDir . $favicon_filename;
if (move_uploaded_file($_FILES['site_favicon']['tmp_name'], $favicon_path)) {
$upsert_stmt->execute(['site_favicon', $favicon_path . '?v=' . time()]);
} else {
$_SESSION['flash_message'] = ['text' => 'Failed to move uploaded favicon.', 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Invalid file type for favicon.', 'type' => 'danger'];
}
}
if (!isset($_SESSION['flash_message'])) {
$_SESSION['flash_message'] = ['text' => translate('settings_save_success'), 'type' => 'success'];
}
} elseif ($action === 'update_user') {
$username = trim($_POST['username'] ?? '');
$new_password = $_POST['new_password'] ?? '';
$confirm_password = $_POST['confirm_password'] ?? '';
$user_id = $_SESSION['user_id'];
if (empty($username)) {
$_SESSION['flash_message'] = ['text' => 'Username cannot be empty.', 'type' => 'warning'];
} else {
// Check if password needs to be updated
if (!empty($new_password)) {
if ($new_password === $confirm_password) {
$hashed_password = password_hash($new_password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE users SET username = ?, password = ? WHERE id = ?");
$stmt->execute([$username, $hashed_password, $user_id]);
$_SESSION['flash_message'] = ['text' => translate('settings_user_update_success'), 'type' => 'success'];
$_SESSION['username'] = $username; // Update session username
} else {
$_SESSION['flash_message'] = ['text' => translate('settings_password_mismatch'), 'type' => 'danger'];
}
} else {
// Update username only
$stmt = $pdo->prepare("UPDATE users SET username = ? WHERE id = ?");
$stmt->execute([$username, $user_id]);
$_SESSION['flash_message'] = ['text' => translate('settings_user_update_success'), 'type' => 'success'];
$_SESSION['username'] = $username; // Update session username
}
}
} elseif ($action === 'update_theme') {
$upsert_stmt = $pdo->prepare("REPLACE INTO settings (key, value) VALUES (?, ?)");
$upload_error = false;
// Handle file uploads
$backgroundUploadDir = 'data/images/backgrounds/';
if (!is_dir($backgroundUploadDir)) {
mkdir($backgroundUploadDir, 0755, true);
}
// Handle Background Image Upload
if (isset($_FILES['background_image']) && $_FILES['background_image']['error'] === UPLOAD_ERR_OK) {
$bg_info = getimagesize($_FILES['background_image']['tmp_name']);
if ($bg_info !== false) { // It's an image
// Find and delete any existing global background
$existing_files = glob($backgroundUploadDir . 'global-bg.*');
foreach ($existing_files as $file_to_delete) {
unlink($file_to_delete);
}
$bg_ext = strtolower(pathinfo($_FILES['background_image']['name'], PATHINFO_EXTENSION));
$bg_filename = 'global-bg.' . $bg_ext;
$bg_path = $backgroundUploadDir . $bg_filename;
if (move_uploaded_file($_FILES['background_image']['tmp_name'], $bg_path)) {
// Save the new path with a cache-busting query string
$_POST['background_image_url'] = $bg_path . '?v=' . time();
} else {
$_SESSION['flash_message'] = ['text' => 'Failed to move uploaded background image.', 'type' => 'danger'];
$upload_error = true;
}
} else {
$_SESSION['flash_message'] = ['text' => 'Invalid file type for background. Please upload an image.', 'type' => 'danger'];
$upload_error = true;
}
}
$theme_keys = ['theme', 'background_image_url', 'background_brightness', 'background_saturation', 'pexels_api_key', 'custom_css', 'theme_transparency', 'custom_footer_html'];
foreach ($theme_keys as $key) {
if (isset($_POST[$key])) {
$upsert_stmt->execute([$key, trim($_POST[$key])]);
}
}
if (!$upload_error) {
$_SESSION['flash_message'] = ['text' => 'Theme settings saved successfully.', 'type' => 'success'];
}
} elseif ($action === 'restore_backup') {
if (isset($_FILES['backup_file']) && $_FILES['backup_file']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['backup_file']['tmp_name'];
$zip = new ZipArchive;
if ($zip->open($file) === TRUE) {
// Check for essential files/directories in the zip
if ($zip->locateName('database/bookmarkly.db') !== false) {
// Correct paths for scripts running in the root directory
$dataDir = 'data';
$extractPath = 'data/';
// Important: ensure the old data is removed or backed up before extraction
// A more robust solution would backup the current 'data' dir first.
// Clear the data directory before restoring, only if it exists
if (is_dir($dataDir)) {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dataDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $fileinfo) {
if ($fileinfo->isDir()) {
@rmdir($fileinfo->getRealPath());
} else {
@unlink($fileinfo->getRealPath());
}
}
}
// Ensure the target directory exists
if (!is_dir($extractPath)) {
mkdir($extractPath, 0755, true);
}
// Extract the new data
$zip->extractTo($extractPath);
$zip->close();
session_destroy();
$_SESSION['flash_message'] = ['text' => translate('settings_backup_restore_success'), 'type' => 'success'];
header('Location: login.php');
exit;
} else {
$_SESSION['flash_message'] = ['text' => translate('settings_backup_restore_failed'), 'type' => 'danger'];
}
$zip->close();
} else {
$_SESSION['flash_message'] = ['text' => translate('settings_backup_restore_failed'), 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => translate('settings_backup_restore_failed'), 'type' => 'danger'];
}
}
// Redirect back to settings page for all actions except a successful restore
header('Location: settings.php');
exit;
}
// Fetch tabs for the new import form
$tabs_stmt = $pdo->prepare("SELECT id, name FROM tabs");
$tabs_stmt->execute();
$tabs = $tabs_stmt->fetchAll();
require_once 'include/header.php';
?>
<div class="container mt-4">
<h1 class="mb-4"><?php echo translate('settings_title'); ?></h1>
<?php if ($message): ?>
<div class="alert alert-<?php echo $message_type; ?> alert-dismissible fade show" role="alert">
<?php echo htmlspecialchars($message); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<div class="row">
<div class="col-md-2">
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical">
<button class="nav-link active" id="v-pills-general-tab" data-bs-toggle="pill" data-bs-target="#v-pills-general" type="button" role="tab"><?php echo translate('settings_general_tab'); ?></button>
<button class="nav-link" id="v-pills-theme-tab" data-bs-toggle="pill" data-bs-target="#v-pills-theme" type="button" role="tab"><?php echo translate('settings_theme_tab'); ?></button>
<button class="nav-link" id="v-pills-backup-tab" data-bs-toggle="pill" data-bs-target="#v-pills-backup" type="button" role="tab"><?php echo translate('settings_backup_tab'); ?></button>
<?php if (isAdmin()): ?>
<button class="nav-link" id="v-pills-system-tab" data-bs-toggle="pill" data-bs-target="#v-pills-system" type="button" role="tab">System Maintenance</button>
<?php endif; ?>
</div>
</div>
<div class="col-md-10">
<div class="tab-content" id="v-pills-tabContent">
<!-- General Settings Tab -->
<div class="tab-pane fade show active" id="v-pills-general" role="tabpanel">
<div class="card">
<div class="card-header">
<h4><?php echo translate('settings_general_title'); ?></h4>
</div>
<div class="card-body">
<form action="settings.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="update_settings">
<div class="mb-3">
<label for="site_title" class="form-label">Site Title</label>
<input type="text" class="form-control" id="site_title" name="site_title" value="<?php echo htmlspecialchars($settings['site_title'] ?? 'Bookmarkly'); ?>">
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="display_site_title" name="display_site_title" value="1" <?php echo ($settings['display_site_title'] ?? '1') === '1' ? 'checked' : ''; ?>>
<label class="form-check-label" for="display_site_title">Display Site Title</label>
</div>
<div class="mb-3">
<label for="site_logo" class="form-label">Site Logo</label>
<input type="file" class="form-control" id="site_logo" name="site_logo">
<?php if (!empty($settings['site_logo'])): ?>
<small class="form-text text-muted d-block mt-2">Current logo: <img src="<?php echo htmlspecialchars($settings['site_logo']); ?>" alt="Current Logo" style="max-height: 24px; vertical-align: middle;"></small>
<?php endif; ?>
</div>
<div class="mb-3">
<label for="logo_height" class="form-label">Logo Height: <span id="logo_height_value"><?php echo htmlspecialchars($settings['logo_height'] ?? '30'); ?>px</span></label>
<input type="range" class="form-range" min="10" max="30" id="logo_height" name="logo_height" value="<?php echo htmlspecialchars($settings['logo_height'] ?? '30'); ?>" oninput="document.getElementById('logo_height_value').textContent = this.value + 'px';">
</div>
<div class="mb-3">
<label for="site_favicon" class="form-label">Site Favicon</label>
<input type="file" class="form-control" id="site_favicon" name="site_favicon">
<?php if (!empty($settings['site_favicon'])): ?>
<small class="form-text text-muted d-block mt-2">Current favicon: <img src="<?php echo htmlspecialchars($settings['site_favicon']); ?>" alt="Current Favicon" style="max-height: 16px; vertical-align: middle;"></small>
<?php endif; ?>
</div>
<hr>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="open_new_tab" name="open_new_tab" value="1" <?php echo ($settings['open_new_tab'] ?? '0') === '1' ? 'checked' : ''; ?>>
<label class="form-check-label" for="open_new_tab"><?php echo translate('settings_open_new_tab'); ?></label>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="dashboard_protected" name="dashboard_protected" value="1" <?php echo ($settings['dashboard_protected'] ?? '0') === '1' ? 'checked' : ''; ?>>
<label class="form-check-label" for="dashboard_protected"><?php echo translate('settings_dashboard_protection'); ?></label>
</div>
<div class="mb-3">
<label for="remember_me_duration" class="form-label"><?php echo translate('settings_stay_logged_in'); ?></label>
<select class="form-select" id="remember_me_duration" name="remember_me_duration">
<option <?php echo ($settings['remember_me_duration'] ?? '') === '1 day' ? 'selected' : ''; ?>>1 day</option>
<option <?php echo ($settings['remember_me_duration'] ?? '') === '2 weeks' ? 'selected' : ''; ?>>2 weeks</option>
<option <?php echo ($settings['remember_me_duration'] ?? '') === '1 month' ? 'selected' : ''; ?>>1 month</option>
<option <?php echo ($settings['remember_me_duration'] ?? '') === '3 months' ? 'selected' : ''; ?>>3 months</option>
<option <?php echo ($settings['remember_me_duration'] ?? '') === '1 year' ? 'selected' : ''; ?>>1 year</option>
</select>
</div>
<div class="mb-3">
<label for="language" class="form-label"><?php echo translate('settings_language'); ?></label>
<select class="form-select" id="language" name="language">
<option value="en" <?php echo ($settings['language'] ?? 'en') === 'en' ? 'selected' : ''; ?>>English</option>
<option value="nl" <?php echo ($settings['language'] ?? 'en') === 'nl' ? 'selected' : ''; ?>>Nederlands</option>
</select>
</div>
<div class="mb-3">
<label for="favicon_extractor_url" class="form-label"><?php echo translate('settings_favicon_extractor_url'); ?></label>
<input type="url" class="form-control" id="favicon_extractor_url" name="favicon_extractor_url" value="<?php echo htmlspecialchars($settings['favicon_extractor_url'] ?? ''); ?>" placeholder="http://localhost:3000">
<div class="form-text"><?php echo translate('settings_favicon_extractor_url_help'); ?></div>
</div>
<button type="submit" class="btn btn-primary"><?php echo translate('settings_save_settings_button'); ?></button>
</form>
<hr>
<h5><?php echo translate('settings_user_account_title'); ?></h5>
<form action="settings.php" method="POST">
<input type="hidden" name="action" value="update_user">
<div class="mb-3">
<label for="username" class="form-label"><?php echo translate('username'); ?></label>
<input type="text" class="form-control" id="username" name="username" value="<?php echo htmlspecialchars($_SESSION['username'] ?? ''); ?>" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label"><?php echo translate('settings_new_password'); ?></label>
<input type="password" class="form-control" id="new_password" name="new_password">
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label"><?php echo translate('settings_confirm_password'); ?></label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password">
</div>
<button type="submit" class="btn btn-primary"><?php echo translate('settings_update_profile_button'); ?></button>
</form>
</div>
</div>
</div>
<!-- Theme & Background Tab -->
<div class="tab-pane fade" id="v-pills-theme" role="tabpanel">
<div class="card">
<div class="card-header">
<h4><?php echo translate('settings_theme_title'); ?></h4>
</div>
<div class="card-body">
<p class="text-muted">These settings serve as the global default. They can be overridden by individual tabs in the <a href="tabs_settings.php">Tab Management</a> page.</p>
<hr>
<form action="settings.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="update_theme">
<div class="mb-3">
<label for="theme" class="form-label"><?php echo translate('settings_theme'); ?></label>
<select class="form-select" id="theme" name="theme">
<option value="light" <?php echo ($settings['theme'] ?? 'light') === 'light' ? 'selected' : ''; ?>><?php echo translate('settings_theme_light'); ?></option>
<option value="dark" <?php echo ($settings['theme'] ?? 'light') === 'dark' ? 'selected' : ''; ?>><?php echo translate('settings_theme_dark'); ?></option>
<option value="transparent" <?php echo ($settings['theme'] ?? 'light') === 'transparent' ? 'selected' : ''; ?>><?php echo translate('settings_theme_transparent'); ?></option>
</select>
</div>
<div class="mb-3" id="transparency-slider-container">
<label for="theme_transparency" class="form-label"><?php echo translate('settings_theme_transparency'); ?>: <span id="theme_transparency_value"><?php echo htmlspecialchars($settings['theme_transparency'] ?? '85'); ?>%</span></label>
<input type="range" class="form-range" min="0" max="100" id="theme_transparency" name="theme_transparency" value="<?php echo htmlspecialchars($settings['theme_transparency'] ?? '85'); ?>" oninput="document.getElementById('theme_transparency_value').textContent = this.value + '%';">
</div>
<div class="mb-3">
<label for="background_image" class="form-label"><?php echo translate('settings_background_upload'); ?></label>
<input type="file" class="form-control" id="background_image" name="background_image" accept="image/*">
<?php
$current_bg = $settings['background_image_url'] ?? '';
// Sanitize path for file_exists check
$bg_path_for_check = strtok($current_bg, '?');
if (!empty($current_bg) && file_exists($bg_path_for_check) && strpos($bg_path_for_check, 'data/images/backgrounds/') === 0):
?>
<small class="form-text text-muted d-block mt-2">
<?php echo translate('settings_background_current'); ?> <a href="<?php echo htmlspecialchars($current_bg); ?>" target="_blank"><?php echo basename($bg_path_for_check); ?></a>
<img src="<?php echo htmlspecialchars($current_bg); ?>" alt="Current Background" style="max-height: 40px; vertical-align: middle; margin-left: 10px;">
<button type="button" class="btn btn-sm btn-secondary ms-2" onclick="optimizeImage('<?php echo htmlspecialchars($current_bg); ?>')"><?php echo translate('settings_background_optimize'); ?></button>
</small>
<?php endif; ?>
</div>
<div class="mb-3">
<label for="background_image_url" class="form-label"><?php echo translate('settings_background_url'); ?></label>
<input type="text" class="form-control" id="background_image_url" name="background_image_url" value="<?php echo htmlspecialchars($settings['background_image_url'] ?? ''); ?>" placeholder="Enter image URL or search Pexels">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="background_brightness" class="form-label"><?php echo translate('settings_background_brightness'); ?>: <span id="background_brightness_value"><?php echo htmlspecialchars($settings['background_brightness'] ?? '100'); ?>%</span></label>
<input type="range" class="form-range" min="0" max="200" id="background_brightness" name="background_brightness" value="<?php echo htmlspecialchars($settings['background_brightness'] ?? '100'); ?>" oninput="document.getElementById('background_brightness_value').textContent = this.value + '%';">
</div>
<div class="col-md-6 mb-3">
<label for="background_saturation" class="form-label"><?php echo translate('settings_background_saturation'); ?>: <span id="background_saturation_value"><?php echo htmlspecialchars($settings['background_saturation'] ?? '100'); ?>%</span></label>
<input type="range" class="form-range" min="0" max="200" id="background_saturation" name="background_saturation" value="<?php echo htmlspecialchars($settings['background_saturation'] ?? '100'); ?>" oninput="document.getElementById('background_saturation_value').textContent = this.value + '%';">
</div>
</div>
<hr>
<h5><?php echo translate('settings_pexels_integration'); ?></h5>
<div class="mb-3">
<label for="pexels_api_key" class="form-label"><?php echo translate('settings_pexels_api_key'); ?></label>
<input type="password" class="form-control" id="pexels_api_key" name="pexels_api_key" value="<?php echo htmlspecialchars($settings['pexels_api_key'] ?? ''); ?>" placeholder="Enter your Pexels API key">
</div>
<div class="input-group mb-3">
<input type="text" id="pexels_search_query" class="form-control" placeholder="<?php echo translate('settings_pexels_search_placeholder'); ?>" <?php echo empty($settings['pexels_api_key']) ? 'disabled' : ''; ?>>
<button class="btn btn-secondary" type="button" id="pexels_search_button" <?php echo empty($settings['pexels_api_key']) ? 'disabled' : ''; ?>>
<i class="bi bi-search"></i> <?php echo translate('settings_pexels_search_button'); ?>
</button>
</div>
<div id="pexels_results" class="row row-cols-3 g-2"></div>
<hr>
<h5><?php echo translate('settings_custom_css'); ?></h5>
<div class="mb-3">
<label for="custom_css" class="form-label"><?php echo translate('settings_custom_css'); ?></label>
<textarea class="form-control" id="custom_css" name="custom_css" rows="5"><?php echo htmlspecialchars($settings['custom_css'] ?? ''); ?></textarea>
</div>
<hr>
<h5><?php echo translate('settings_custom_footer'); ?></h5>
<div class="mb-3">
<label for="custom_footer_html" class="form-label"><?php echo translate('settings_custom_footer'); ?></label>
<textarea class="form-control" id="custom_footer_html" name="custom_footer_html" rows="5"><?php echo htmlspecialchars($settings['custom_footer_html'] ?? ''); ?></textarea>
</div>
<button type="submit" class="btn btn-primary"><?php echo translate('settings_save_theme_button'); ?></button>
</form>
</div>
</div>
</div>
<!-- Backup & Restore Tab -->
<div class="tab-pane fade" id="v-pills-backup" role="tabpanel">
<div class="card">
<div class="card-header">
<h4><?php echo translate('settings_backup_title'); ?></h4>
</div>
<div class="card-body">
<h5><?php echo translate('settings_backup_create_title'); ?></h5>
<p><?php echo translate('settings_backup_text'); ?></p>
<form action="api/backup.php" method="GET" class="mb-4">
<div class="input-group">
<select class="form-select" name="tab_id" id="backup_tab_id">
<option value="full"><?php echo translate('settings_backup_full'); ?></option>
<?php foreach ($tabs as $tab): ?>
<option value="<?php echo htmlspecialchars($tab['id']); ?>">
<?php echo translate('settings_backup_tab_prefix'); ?>: <?php echo htmlspecialchars($tab['name']); ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-primary"><?php echo translate('settings_backup_create_button'); ?></button>
</div>
</form>
<hr>
<h5><?php echo translate('settings_backup_restore_title'); ?></h5>
<p><?php echo translate('settings_backup_restore_text'); ?></p>
<form action="settings.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="restore_backup">
<div class="mb-3">
<label for="backup_file" class="form-label"><?php echo translate('settings_backup_restore_input'); ?></label>
<input class="form-control" type="file" id="backup_file" name="backup_file" accept=".zip" required>
</div>
<button type="submit" class="btn btn-warning"><?php echo translate('settings_backup_restore_button'); ?></button>
</form>
<hr class="my-4">
<h5><?php echo translate('settings_import_title'); ?></h5>
<p><?php echo translate('settings_import_text'); ?></p>
<form action="api/import_backup.php" method="POST" enctype="multipart/form-data" id="import-backup-form">
<div class="mb-3">
<label for="import_backup_file" class="form-label"><?php echo translate('settings_import_input'); ?></label>
<input class="form-control" type="file" id="import_backup_file" name="backup_file" accept=".zip" required>
</div>
<div class="mb-3">
<label for="target_tab_id" class="form-label"><?php echo translate('settings_import_select_tab'); ?></label>
<select class="form-select" id="target_tab_id" name="target_tab_id" required>
<option value=""><?php echo translate('settings_import_select_tab_placeholder'); ?></option>
<?php foreach ($tabs as $tab): ?>
<option value="<?php echo htmlspecialchars($tab['id']); ?>">
<?php echo htmlspecialchars($tab['name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-info"><?php echo translate('settings_import_button'); ?></button>
</form>
</div>
</div>
</div>
<!-- System Tab -->
<?php if (isAdmin()): ?>
<div class="tab-pane fade" id="v-pills-system" role="tabpanel">
<div class="card">
<div class="card-header">
<h4>System Maintenance</h4>
</div>
<div class="card-body">
<h5>Update Database Schema</h5>
<p>If new features require database changes, you can apply them here. This process is safe to run multiple times.</p>
<button class="btn btn-info" id="update-db-button">Update Database</button>
<div id="update-db-result" class="mt-3"></div>
<hr class="my-4">
<h5><?php echo translate('settings_maintenance_fix_orphaned_bookmarks_title'); ?></h5>
<p><?php echo translate('settings_maintenance_fix_orphaned_bookmarks_desc'); ?></p>
<button class="btn btn-warning" id="fix-bookmarks-button"><?php echo translate('settings_maintenance_fix_orphaned_bookmarks_button'); ?></button>
<div id="fix-bookmarks-result" class="mt-3"></div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php require_once 'include/footer.php'; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
const updateDbBtn = document.getElementById('update-db-button');
const updateDbResult = document.getElementById('update-db-result');
if (updateDbBtn) {
updateDbBtn.addEventListener('click', async function() {
updateDbBtn.disabled = true;
updateDbResult.innerHTML = `<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div> Checking for updates...`;
try {
const response = await fetch('api/update_database.php', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (response.ok && data.success) {
let detailsHtml = data.details.map(d => `<li>${d}</li>`).join('');
updateDbResult.innerHTML = `<div class="alert alert-success">
<strong>${data.message}</strong>
<ul>${detailsHtml}</ul>
</div>`;
} else {
throw new Error(data.message || 'An unknown error occurred.');
}
} catch (error) {
updateDbResult.innerHTML = `<div class="alert alert-danger"><strong>Error:</strong> ${error.message}</div>`;
} finally {
updateDbBtn.disabled = false;
}
});
}
// --- Handle Fix Orphaned Bookmarks ---
const fixBookmarksBtn = document.getElementById('fix-bookmarks-button');
const fixBookmarksResult = document.getElementById('fix-bookmarks-result');
if (fixBookmarksBtn) {
fixBookmarksBtn.addEventListener('click', async function() {
fixBookmarksBtn.disabled = true;
fixBookmarksResult.innerHTML = `<div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div> Searching for orphaned bookmarks...`;
try {
const response = await fetch('api/fix_orphaned_bookmarks.php', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (response.ok && data.success) {
fixBookmarksResult.innerHTML = `<div class="alert alert-success"><strong>${data.message}</strong></div>`;
} else {
throw new Error(data.message || 'An unknown error occurred.');
}
} catch (error) {
fixBookmarksResult.innerHTML = `<div class="alert alert-danger"><strong>Error:</strong> ${error.message}</div>`;
} finally {
fixBookmarksBtn.disabled = false;
}
});
}
// --- New: Handle Backup Import to Tab ---
const importForm = document.getElementById('import-backup-form');
if (importForm) {
importForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitButton = importForm.querySelector('button[type="submit"]');
const originalButtonText = submitButton.innerHTML;
let resultContainer = document.getElementById('import-result');
if (!resultContainer) {
resultContainer = document.createElement('div');
resultContainer.id = 'import-result';
resultContainer.className = 'mt-3';
importForm.appendChild(resultContainer);
}
submitButton.disabled = true;
submitButton.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Importing...`;
resultContainer.innerHTML = '';
const formData = new FormData(importForm);
try {
const response = await fetch('api/import_backup.php', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
resultContainer.innerHTML = `<div class="alert alert-success">${data.message}</div>`;
importForm.reset(); // Clear the form
} else {
const err = new Error(data.message || 'An unknown error occurred during import.');
if (data.error_details) {
err.details = data.error_details;
}
throw err;
}
} catch (error) {
let errorMessage = `<strong>Error:</strong> ${error.message}`;
if (error.details) {
errorMessage += `<br><small class="text-muted"><strong>Details:</strong> ${error.details.message} in <strong>${error.details.file}</strong> on line <strong>${error.details.line}</strong></small>`;
}
resultContainer.innerHTML = `<div class="alert alert-danger">${errorMessage}</div>`;
} finally {
submitButton.disabled = false;
submitButton.innerHTML = originalButtonText;
}
});
}
});
</script>
+702
View File
@@ -0,0 +1,702 @@
<?php
$page_title = 'Manage Tabs - Bookmarkly';
require_once 'include/auth.php';
// --- Security Check: Only Admin can access this page ---
if (!isAdmin()) {
header('Location: index.php');
exit;
}
// Fetch all users for various UI elements
$users_stmt = $pdo->query("SELECT id, username, role FROM users ORDER BY username");
$users = $users_stmt->fetchAll();
require_once 'include/header.php';
?>
<div class="container mt-4">
<h1>Manage Tabs</h1>
<!-- Add Tab Form -->
<div class="card mb-4">
<div class="card-header">Add New Tab</div>
<div class="card-body">
<form id="add-tab-form">
<div class="row">
<div class="col-md-6 mb-3">
<label for="new-tab-name" class="form-label">Tab Name</label>
<input type="text" class="form-control" id="new-tab-name" required>
</div>
<div class="col-md-4 mb-3">
<label for="new-tab-user" class="form-label">Assign to User (Owner)</label>
<select id="new-tab-user" class="form-select">
<option value="">None (Public)</option>
<?php foreach ($users as $user): ?>
<?php if ($user['role'] === 'user' || $user['role'] === 'admin'): ?>
<option value="<?php echo $user['id']; ?>"><?php echo htmlspecialchars($user['username']); ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2 d-flex align-items-end mb-3">
<button type="submit" class="btn btn-primary w-100">Add Tab</button>
</div>
</div>
</form>
</div>
</div>
<!-- Tab List & Permissions Matrix -->
<div class="card">
<div class="card-header">Existing Tabs & Permissions</div>
<div class="card-body">
<div id="tabs-matrix-container">
<!-- Tabs and matrix will be loaded here via JavaScript -->
</div>
</div>
</div>
</div>
<!-- Edit Tab Modal -->
<div class="modal fade" id="editTabModal" tabindex="-1" aria-labelledby="editTabModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editTabModalLabel">Edit Tab</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-tab-id">
<div class="mb-3">
<label for="edit-tab-name" class="form-label">Tab Name</label>
<input type="text" class="form-control" id="edit-tab-name" required>
</div>
<div class="mb-3">
<label for="edit-tab-owner" class="form-label">Owner</label>
<select id="edit-tab-owner" class="form-select">
<option value="">None (Public)</option>
<?php foreach ($users as $user): ?>
<?php if ($user['role'] === 'user' || $user['role'] === 'admin'): ?>
<option value="<?php echo $user['id']; ?>"><?php echo htmlspecialchars($user['username']); ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="edit-tab-column-count" class="form-label">Column Count (Tablet/Desktop)</label>
<select id="edit-tab-column-count" class="form-select">
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
<div class="mb-3">
<label for="edit-tab-login-url" class="form-label"><?php echo translate('tab_unique_url_label'); ?></label>
<div class="input-group">
<input type="text" class="form-control" id="edit-tab-login-url" readonly style="cursor: pointer;" data-bs-toggle="tooltip" title="<?php echo translate('tooltip_copy_to_clipboard'); ?>">
<button class="btn btn-outline-secondary" id="generate-token-btn-modal" type="button" title="<?php echo translate('tab_generate_token_button'); ?>"><i class="bi bi-arrow-clockwise"></i></button>
<button class="btn btn-outline-danger" id="clear-token-btn-modal" type="button" title="<?php echo translate('tab_clear_token_button'); ?>"><i class="bi bi-trash"></i></button>
</div>
</div>
<div class="d-grid">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#themeSettingsModal">
<i class="bi bi-palette-fill"></i> Edit Tab Theme & Background
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="save-tab-changes">Save Changes</button>
</div>
</div>
</div>
</div>
<!-- Theme & Background Settings Modal -->
<div class="modal fade" id="themeSettingsModal" tabindex="-1" aria-labelledby="themeSettingsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="themeSettingsModalLabel">Tab Theme & Background Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted">These settings will override the global theme for this tab only. Leave fields blank to use the global default.</p>
<hr>
<!-- Theme -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="edit-tab-theme" class="form-label">Theme</label>
<select class="form-select" id="edit-tab-theme">
<option value="">-- Use Global Default --</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="transparent">Transparent</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="edit-tab-theme-transparency" class="form-label">Theme Transparency: <span id="transparency-value-tab">85</span>%</label>
<input type="range" class="form-range" id="edit-tab-theme-transparency" min="0" max="100" value="85">
</div>
</div>
<hr>
<!-- Pexels Search -->
<div class="row g-3 mb-3">
<div class="col-md-9">
<label for="pexels_search_query_tab" class="form-label">Search Pexels for a background</label>
<input type="text" id="pexels_search_query_tab" class="form-control" placeholder="e.g., nature, city, abstract">
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-info w-100" type="button" id="pexels_search_button_tab">Search</button>
</div>
</div>
<div id="pexels_results_tab" class="row row-cols-2 row-cols-md-4 g-2 mb-4" style="max-height: 200px; overflow-y: auto;"></div>
<!-- Custom Background Upload -->
<div class="mb-3">
<label for="tab-bg-upload" class="form-label">Or upload your own background</label>
<input class="form-control" type="file" id="tab-bg-upload" accept="image/*">
<div id="upload-status" class="form-text"></div>
</div>
<!-- Background URL -->
<div class="mb-3">
<label for="edit-tab-bg-url" class="form-label">Background Image URL</label>
<input type="text" class="form-control" id="edit-tab-bg-url" placeholder="Paste a URL or upload/search to fill">
<div id="current-bg-info" class="mt-2"></div>
</div>
<!-- Background Brightness -->
<div class="mb-3">
<label for="edit-tab-bg-brightness" class="form-label">Background Brightness: <span id="brightness-value-tab">100</span>%</label>
<input type="range" class="form-range" id="edit-tab-bg-brightness" min="0" max="150" value="100">
</div>
<!-- Background Saturation -->
<div class="mb-3">
<label for="edit-tab-bg-saturation" class="form-label">Background Saturation: <span id="saturation-value-tab">100</span>%</label>
<input type="range" class="form-range" id="edit-tab-bg-saturation" min="0" max="200" value="100">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger me-auto" id="remove-tab-background">Remove Background</button>
<button type="button" class="btn btn-primary" data-bs-target="#editTabModal" data-bs-toggle="modal">Apply and Go Back</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const addTabForm = document.getElementById('add-tab-form');
const newTabNameInput = document.getElementById('new-tab-name');
const newTabUserInput = document.getElementById('new-tab-user');
const matrixContainer = document.getElementById('tabs-matrix-container');
const editTabModal = new bootstrap.Modal(document.getElementById('editTabModal'));
const saveTabChangesBtn = document.getElementById('save-tab-changes');
// Background Modal elements
const themeSettingsModal = new bootstrap.Modal(document.getElementById('themeSettingsModal'));
const pexelsSearchBtnTab = document.getElementById('pexels_search_button_tab');
const pexelsQueryInputTab = document.getElementById('pexels_search_query_tab');
const pexelsResultsContainerTab = document.getElementById('pexels_results_tab');
const bgUrlInputTab = document.getElementById('edit-tab-bg-url');
const bgBrightnessInputTab = document.getElementById('edit-tab-bg-brightness');
const bgSaturationInputTab = document.getElementById('edit-tab-bg-saturation');
const removeTabBgBtn = document.getElementById('remove-tab-background');
const bgUploadInput = document.getElementById('tab-bg-upload');
const uploadStatusDiv = document.getElementById('upload-status');
const currentBgInfoDiv = document.getElementById('current-bg-info');
// Theme elements
const themeInput = document.getElementById('edit-tab-theme');
const transparencyInput = document.getElementById('edit-tab-theme-transparency');
const transparencyValueSpan = document.getElementById('transparency-value-tab');
const users = <?php echo json_encode($users); ?>;
const translations = {
defaultTabName: "<?php echo translate('tab_default_name'); ?>"
};
let tabsData = [];
let visibilityData = [];
// Main function to fetch all data and render the matrix
async function initializeMatrix() {
matrixContainer.innerHTML = '<p>Loading...</p>';
try {
const [tabsResponse, visibilityResponse] = await Promise.all([
fetch('api/tabs.php'),
fetch('api/tab_visibility.php')
]);
if (!tabsResponse.ok || !visibilityResponse.ok) {
throw new Error('Failed to fetch initial data.');
}
tabsData = await tabsResponse.json();
visibilityData = await visibilityResponse.json();
renderMatrix(tabsData);
} catch (error) {
console.error('Initialization Error:', error);
matrixContainer.innerHTML = '<p class="text-danger">Failed to load tab permissions.</p>';
}
}
// Render the entire matrix of tabs and user permissions
function renderMatrix(tabs) {
if (tabs.length === 0) {
matrixContainer.innerHTML = '<p>No tabs found.</p>';
return;
}
let html = '<div class="table-responsive"><table class="table table-bordered align-middle">';
// Header Row
html += `
<thead>
<tr class="text-center">
<th style="width: 25%;">Tab Name</th>
`;
users.forEach(user => {
html += `<th>${escapeHTML(user.username)} <small class="text-muted">(${user.role})</small></th>`;
});
html += '<th style="width: 15%;">Actions</th>';
html += '</tr></thead>';
// Body Rows (one for each tab)
html += '<tbody>';
tabs.forEach(tab => {
const displayName = tab.name === 'Default' ? translations.defaultTabName : tab.name;
html += `<tr>`;
html += `<td>
<strong>${escapeHTML(displayName)}</strong>
<div class="btn-group btn-group-sm float-end">
<button class="btn btn-outline-secondary edit-tab-btn" data-tab-id="${tab.id}" title="Edit Tab"><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger delete-tab-btn" data-tab-id="${tab.id}" title="Delete Tab"><i class="bi bi-trash"></i></button>
</div>
</td>`;
users.forEach(user => {
const isVisible = !visibilityData.some(v => v.user_id == user.id && v.tab_id == tab.id);
const isOwner = tab.user_id == user.id;
let radioHtml = `
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="visibility-${tab.id}-${user.id}" id="show-${tab.id}-${user.id}" value="1" ${isVisible ? 'checked' : ''}>
<label class="form-check-label" for="show-${tab.id}-${user.id}"><i class="bi bi-eye"></i></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="visibility-${tab.id}-${user.id}" id="hide-${tab.id}-${user.id}" value="0" ${!isVisible ? 'checked' : ''}>
<label class="form-check-label" for="hide-${tab.id}-${user.id}"><i class="bi bi-eye-slash"></i></label>
</div>
`;
if (isOwner) {
radioHtml += '<div class="text-muted fst-italic" style="font-size: 0.8em;">Owner</div>';
}
html += `<td class="text-center" data-tab-id="${tab.id}" data-user-id="${user.id}">${radioHtml}</td>`;
});
// Actions Cell
const deleteDisabled = tab.id == 1 ? 'disabled' : '';
html += `
<td class="text-center">
<button class="btn btn-sm btn-outline-secondary me-2 edit-tab-btn" data-tab-id="${tab.id}" data-tab-name="${escapeHTML(tab.name)}">Edit</button>
<button class="btn btn-sm btn-outline-danger delete-tab-btn" data-tab-id="${tab.id}" ${deleteDisabled}>Delete</button>
</td>
`;
html += '</tr>';
});
html += '</tbody></table></div>';
matrixContainer.innerHTML = html;
}
// Event Delegation for radio button changes and other actions
matrixContainer.addEventListener('click', async function(event) {
const button = event.target.closest('button');
if (!button) {
// Handle radio button changes if a button was not the source
if (event.target.type === 'radio') {
const td = event.target.closest('td');
const tabId = td.dataset.tabId;
const userId = td.dataset.userId;
const isVisible = event.target.value === '1';
try {
const response = await fetch('api/tab_visibility.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tab_id: tabId, user_id: userId, is_visible: isVisible })
});
if (!response.ok) throw new Error('Failed to update visibility.');
// Update local data and re-render for consistency
const visibilityIndex = visibilityData.findIndex(v => v.user_id == userId && v.tab_id == tabId);
if (isVisible && visibilityIndex > -1) {
visibilityData.splice(visibilityIndex, 1);
} else if (!isVisible && visibilityIndex === -1) {
visibilityData.push({user_id: userId, tab_id: tabId});
}
// No full re-render needed, UI updated instantly
} catch (error) {
console.error('Update Error:', error);
alert('Error updating visibility. Please refresh the page.');
}
}
return;
}
// Handle button clicks
const tabId = button.dataset.tabId;
const tabData = tabsData.find(t => t.id == tabId);
if (button.classList.contains('edit-tab-btn')) {
if (tabData) {
document.getElementById('edit-tab-id').value = tabData.id;
document.getElementById('edit-tab-name').value = tabData.name;
document.getElementById('edit-tab-owner').value = tabData.user_id || '';
document.getElementById('edit-tab-column-count').value = tabData.column_count || '5';
// Populate login url
const loginUrlInput = document.getElementById('edit-tab-login-url');
loginUrlInput.value = tabData.login_token ? `${window.location.origin}/index.php?token=${tabData.login_token}` : '';
// Populate theme & background fields
themeInput.value = tabData.theme || '';
transparencyInput.value = tabData.theme_transparency ?? '85';
bgUrlInputTab.value = tabData.background_image_url || '';
bgBrightnessInputTab.value = tabData.background_brightness ?? '100';
bgSaturationInputTab.value = tabData.background_saturation ?? '100';
updateSliderLabels();
updateCurrentBgInfo();
themeSettingsModal.hide(); // Ensure it's hidden before showing main modal
editTabModal.show();
}
}
if (button.classList.contains('delete-tab-btn')) {
if (tabData && confirm(`Are you sure you want to delete the tab "${tabData.name}"? This cannot be undone.`)) {
try {
await fetch('api/tabs.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tabId })
});
await initializeMatrix(); // Refresh the whole matrix
} catch (error) {
console.error('Delete Error:', error);
alert('Error deleting tab.');
}
}
}
});
// Generate Token
async function generateToken(tabId) {
try {
const formData = new FormData();
formData.append('tab_id', tabId);
const response = await fetch('api/generate_token.php', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Failed to generate token.');
const data = await response.json();
if (data.success && data.token) {
const loginUrlInput = document.getElementById('edit-tab-login-url');
const newUrl = `${window.location.origin}/index.php?token=${data.token}`;
loginUrlInput.value = newUrl;
// Update tabsData to reflect the new token
const tab = tabsData.find(t => t.id == tabId);
if (tab) {
tab.login_token = data.token;
}
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (error) {
console.error('Token Generation Error:', error);
alert(`Error generating token: ${error.message}`);
}
}
// Clear Token
async function clearToken(tabId) {
try {
const formData = new FormData();
formData.append('tab_id', tabId);
const response = await fetch('api/clear_token.php', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Failed to clear token.');
const data = await response.json();
if (data.success) {
const loginUrlInput = document.getElementById('edit-tab-login-url');
loginUrlInput.value = '';
// Update tabsData to reflect the cleared token
const tab = tabsData.find(t => t.id == tabId);
if (tab) {
tab.login_token = null;
}
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (error) {
console.error('Token Clearing Error:', error);
alert(`Error clearing token: ${error.message}`);
}
}
document.getElementById('generate-token-btn-modal').addEventListener('click', function() {
const tabId = document.getElementById('edit-tab-id').value;
if (tabId && confirm('<?php echo translate('tab_generate_token_confirm'); ?>')) {
generateToken(tabId);
}
});
document.getElementById('clear-token-btn-modal').addEventListener('click', function() {
const tabId = document.getElementById('edit-tab-id').value;
if (tabId && confirm('<?php echo translate('tab_clear_token_confirm'); ?>')) {
clearToken(tabId);
}
});
// Handle copying the login URL to clipboard
const loginUrlInput = document.getElementById('edit-tab-login-url');
const tooltip = new bootstrap.Tooltip(loginUrlInput, {
trigger: 'hover'
});
loginUrlInput.addEventListener('click', function() {
const url = this.value;
if (url) {
navigator.clipboard.writeText(url).then(() => {
// Success feedback
const originalTitle = this.getAttribute('data-bs-original-title');
this.setAttribute('data-bs-original-title', '<?php echo translate('tooltip_copied'); ?>');
tooltip.show();
// Reset tooltip title after a delay
setTimeout(() => {
this.setAttribute('data-bs-original-title', originalTitle);
tooltip.hide();
}, 2000);
}).catch(err => {
console.error('Failed to copy: ', err);
});
}
});
// Handle saving changes from the Edit Tab modal
saveTabChangesBtn.addEventListener('click', async function() {
const tabId = document.getElementById('edit-tab-id').value;
const name = document.getElementById('edit-tab-name').value.trim();
const ownerId = document.getElementById('edit-tab-owner').value;
const columnCount = document.getElementById('edit-tab-column-count').value;
// Get theme and background values from the other modal's inputs
const theme = document.getElementById('edit-tab-theme').value;
const transparency = document.getElementById('edit-tab-theme-transparency').value;
const backgroundUrl = document.getElementById('edit-tab-bg-url').value.trim();
const backgroundBrightness = document.getElementById('edit-tab-bg-brightness').value;
const backgroundSaturation = document.getElementById('edit-tab-bg-saturation').value;
if (!name) return;
try {
await fetch('api/tabs.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: tabId,
name: name,
user_id: ownerId,
column_count: columnCount,
theme: theme,
theme_transparency: transparency,
background_image_url: backgroundUrl,
background_brightness: backgroundBrightness,
background_saturation: backgroundSaturation
})
});
editTabModal.hide();
await initializeMatrix(); // Refresh matrix with new data
} catch (error) {
console.error('Error updating tab:', error);
alert('Error updating tab.');
}
});
// Handle Add Tab form submission
addTabForm.addEventListener('submit', async function(event) {
event.preventDefault();
const name = newTabNameInput.value.trim();
const userId = newTabUserInput.value;
if (!name) return;
try {
await fetch('api/tabs.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, user_id: userId })
});
newTabNameInput.value = '';
newTabUserInput.value = '';
await initializeMatrix(); // Re-render the whole matrix
} catch (error) {
console.error('Error adding tab:', error);
}
});
// --- Background Modal Logic ---
bgUploadInput.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
const formData = new FormData();
formData.append('background', file);
// We need the tab ID to potentially name the file, let's get it from the main edit modal
const tabId = document.getElementById('edit-tab-id').value;
formData.append('tab_id', tabId);
uploadStatusDiv.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div> Uploading...';
try {
const response = await fetch('api/upload_tab_background.php', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown upload error' }));
throw new Error(errorData.message || 'Upload failed');
}
const data = await response.json();
if (data.success && data.path) {
bgUrlInputTab.value = data.path;
uploadStatusDiv.textContent = 'Upload successful!';
bgUploadInput.value = ''; // Clear the input
} else {
throw new Error(data.message || 'Upload failed to return a path.');
}
} catch (error) {
console.error('Upload error:', error);
uploadStatusDiv.textContent = `Error: ${error.message}`;
}
});
function updateSliderLabels() {
document.getElementById('brightness-value-tab').textContent = bgBrightnessInputTab.value;
document.getElementById('saturation-value-tab').textContent = bgSaturationInputTab.value;
transparencyValueSpan.textContent = transparencyInput.value;
}
function updateCurrentBgInfo() {
const path = bgUrlInputTab.value;
if (path && path.startsWith('data/images/backgrounds/')) {
currentBgInfoDiv.innerHTML = `
<small class="form-text text-muted d-block mt-2">
Current: <a href="${path}" target="_blank">${path.split('/').pop()}</a>
<button type="button" class="btn btn-sm btn-secondary ms-2" onclick="optimizeImage('${path}')">Optimize</button>
</small>`;
} else {
currentBgInfoDiv.innerHTML = '';
}
}
bgBrightnessInputTab.addEventListener('input', updateSliderLabels);
bgSaturationInputTab.addEventListener('input', updateSliderLabels);
transparencyInput.addEventListener('input', updateSliderLabels);
bgUrlInputTab.addEventListener('input', updateCurrentBgInfo);
removeTabBgBtn.addEventListener('click', function() {
bgUrlInputTab.value = '';
bgBrightnessInputTab.value = '100';
bgSaturationInputTab.value = '100';
updateSliderLabels();
updateCurrentBgInfo();
pexelsResultsContainerTab.innerHTML = '<div class="col-12"><div class="alert alert-info">Background removed.</div></div>';
});
const performPexelsSearch = async () => {
const query = pexelsQueryInputTab.value;
if (!query) {
alert('Please enter a search term.');
return;
}
pexelsResultsContainerTab.innerHTML = '<div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div>';
try {
const response = await fetch(`api/pexels.php?query=${encodeURIComponent(query)}`);
const data = await response.json();
pexelsResultsContainerTab.innerHTML = '';
if (data.error) throw new Error(data.error);
if (data.photos && data.photos.length > 0) {
data.photos.forEach(photo => {
const imgContainer = document.createElement('div');
imgContainer.className = 'col';
const img = document.createElement('img');
img.src = photo.src.medium;
img.className = 'img-thumbnail';
img.style.cursor = 'pointer';
img.alt = photo.alt;
img.addEventListener('click', () => {
bgUrlInputTab.value = photo.src.original;
updateCurrentBgInfo();
pexelsResultsContainerTab.innerHTML = '<div class="col-12"><div class="alert alert-success">Background selected! Go back to apply.</div></div>';
});
imgContainer.appendChild(img);
pexelsResultsContainerTab.appendChild(imgContainer);
});
} else {
pexelsResultsContainerTab.innerHTML = '<div class="col-12"><div class="alert alert-warning">No images found.</div></div>';
}
} catch (error) {
console.error('Pexels search error:', error);
pexelsResultsContainerTab.innerHTML = `<div class="col-12"><div class="alert alert-danger">${error.message}</div></div>`;
}
};
pexelsSearchBtnTab.addEventListener('click', performPexelsSearch);
pexelsQueryInputTab.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performPexelsSearch();
});
document.getElementById('themeSettingsModal').addEventListener('hidden.bs.modal', function () {
// Clear search results when modal is closed to keep it clean
pexelsResultsContainerTab.innerHTML = '';
pexelsQueryInputTab.value = '';
uploadStatusDiv.textContent = '';
bgUploadInput.value = '';
});
function escapeHTML(str) {
return str ? str.replace(/[&<>"']/g, match => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[match])) : '';
}
// Initial load
initializeMatrix();
});
</script>
<?php require_once 'include/footer.php'; ?>
+252
View File
@@ -0,0 +1,252 @@
<?php
$page_title = 'Manage Users - Bookmarkly';
require_once 'include/auth.php';
// --- Security Check: Only Admin can access this page ---
if (!isAdmin()) {
header('Location: index.php');
exit;
}
// Flash messages for feedback
$message = '';
$message_type = '';
if (isset($_SESSION['flash_message'])) {
$message = $_SESSION['flash_message']['text'];
$message_type = $_SESSION['flash_message']['type'];
unset($_SESSION['flash_message']);
}
// Handle POST requests for adding/deleting users
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'add_user') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$role = $_POST['role'] ?? 'user';
if (!empty($username) && !empty($password) && in_array($role, ['user', 'viewer', 'admin'])) {
// Check if username already exists
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
if ($stmt->fetch()) {
$_SESSION['flash_message'] = ['text' => 'Username already exists.', 'type' => 'warning'];
} else {
$hashed_password = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, password, role) VALUES (?, ?, ?)");
if ($stmt->execute([$username, $hashed_password, $role])) {
$_SESSION['flash_message'] = ['text' => 'User added successfully.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error adding user.', 'type' => 'danger'];
}
}
} else {
$_SESSION['flash_message'] = ['text' => 'Username, password, and a valid role are required.', 'type' => 'warning'];
}
} elseif ($action === 'edit_user') {
$user_id = filter_input(INPUT_POST, 'user_id', FILTER_VALIDATE_INT);
$username = trim($_POST['username'] ?? '');
$role = $_POST['role'] ?? 'user';
$password = $_POST['password'] ?? ''; // Optional: for changing password
if ($user_id && !empty($username) && in_array($role, ['user', 'viewer', 'admin'])) {
// Check if new username already exists (and is not the current user)
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ? AND id != ?");
$stmt->execute([$username, $user_id]);
if ($stmt->fetch()) {
$_SESSION['flash_message'] = ['text' => 'Username already exists.', 'type' => 'warning'];
} else {
if (!empty($password)) {
// If password is provided, update it
$hashed_password = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("UPDATE users SET username = ?, role = ?, password = ? WHERE id = ?");
$stmt->execute([$username, $role, $hashed_password, $user_id]);
} else {
// Otherwise, update without changing password
$stmt = $pdo->prepare("UPDATE users SET username = ?, role = ? WHERE id = ?");
$stmt->execute([$username, $role, $user_id]);
}
$_SESSION['flash_message'] = ['text' => 'User updated successfully.', 'type' => 'success'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Invalid data for updating user.', 'type' => 'warning'];
}
} elseif ($action === 'delete_user') {
$user_id = filter_input(INPUT_POST, 'user_id', FILTER_VALIDATE_INT);
if ($user_id && $user_id != 1) { // Prevent admin from deleting themselves
$stmt = $pdo->prepare("DELETE FROM users WHERE id = ?");
if ($stmt->execute([$user_id])) {
$_SESSION['flash_message'] = ['text' => 'User deleted successfully.', 'type' => 'success'];
} else {
$_SESSION['flash_message'] = ['text' => 'Error deleting user.', 'type' => 'danger'];
}
} else {
$_SESSION['flash_message'] = ['text' => 'Invalid user or action not allowed.', 'type' => 'danger'];
}
}
header('Location: user_settings.php');
exit;
}
// Fetch all users for display
$users_stmt = $pdo->query("SELECT id, username, role FROM users ORDER BY username");
$users = $users_stmt->fetchAll();
require_once 'include/header.php';
?>
<div class="container mt-4">
<h1>Manage Users</h1>
<?php if ($message): ?>
<div class="alert alert-<?php echo $message_type; ?> alert-dismissible fade show" role="alert">
<?php echo htmlspecialchars($message); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<!-- Add User Form -->
<div class="card mb-4">
<div class="card-header">Add New User</div>
<div class="card-body">
<form action="user_settings.php" method="POST">
<input type="hidden" name="action" value="add_user">
<div class="row">
<div class="col-md-4 mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="col-md-4 mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="col-md-2 mb-3">
<label for="role" class="form-label">Role</label>
<select class="form-select" id="role" name="role">
<option value="user" selected>User</option>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end mb-3">
<button type="submit" class="btn btn-primary w-100">Add User</button>
</div>
</div>
</form>
</div>
</div>
<!-- User List -->
<div class="card">
<div class="card-header">Existing Users</div>
<div class="card-body">
<ul class="list-group">
<?php foreach ($users as $user): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<span class="fw-bold"><?php echo htmlspecialchars($user['username']); ?></span>
<span class="badge bg-secondary ms-2"><?php echo htmlspecialchars($user['role']); ?></span>
</div>
<?php if ($user['id'] != 1): // Don't show delete for admin user ?>
<div>
<button class="btn btn-sm btn-outline-secondary me-2" data-bs-toggle="modal" data-bs-target="#editUserModal" data-user-id="<?php echo $user['id']; ?>" data-username="<?php echo htmlspecialchars($user['username']); ?>" data-role="<?php echo $user['role']; ?>">
Edit
</button>
<form action="user_settings.php" method="POST" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this user?');">
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="user_id" value="<?php echo $user['id']; ?>">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
<!-- Role Descriptions -->
<div class="card mt-4">
<div class="card-header"><?php echo translate('user_settings_roles_title'); ?></div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-2"><?php echo translate('user_settings_role_admin'); ?></dt>
<dd class="col-sm-10"><?php echo translate('user_settings_role_admin_desc'); ?></dd>
<dt class="col-sm-2"><?php echo translate('user_settings_role_user'); ?></dt>
<dd class="col-sm-10"><?php echo translate('user_settings_role_user_desc'); ?></dd>
<dt class="col-sm-2"><?php echo translate('user_settings_role_viewer'); ?></dt>
<dd class="col-sm-10"><?php echo translate('user_settings_role_viewer_desc'); ?></dd>
</dl>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" aria-labelledby="editUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editUserModalLabel">Edit User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="user_settings.php" method="POST">
<div class="modal-body">
<input type="hidden" name="action" value="edit_user">
<input type="hidden" id="edit-user-id" name="user_id">
<div class="mb-3">
<label for="edit-username" class="form-label">Username</label>
<input type="text" class="form-control" id="edit-username" name="username" required>
</div>
<div class="mb-3">
<label for="edit-role" class="form-label">Role</label>
<select class="form-select" id="edit-role" name="role">
<option value="user">User</option>
<option value="viewer">Viewer</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="mb-3">
<label for="edit-password" class="form-label">New Password (optional)</label>
<input type="password" class="form-control" id="edit-password" name="password">
<div class="form-text">Leave blank to keep the current password.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var editUserModal = document.getElementById('editUserModal');
editUserModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var userId = button.getAttribute('data-user-id');
var username = button.getAttribute('data-username');
var role = button.getAttribute('data-role');
var modalTitle = editUserModal.querySelector('.modal-title');
var userIdInput = editUserModal.querySelector('#edit-user-id');
var usernameInput = editUserModal.querySelector('#edit-username');
var roleInput = editUserModal.querySelector('#edit-role');
modalTitle.textContent = 'Edit User: ' + username;
userIdInput.value = userId;
usernameInput.value = username;
roleInput.value = role;
});
});
</script>
<?php require_once 'include/footer.php'; ?>