Quick Launch Board (Full source code included)
What’s new in the Launchboard
Search with autofocus
A new search box filters all cards instantly while you type. When the page loads, the cursor is automatically placed in the search field so you can start typing right away.
Headings with custom colors
You can now insert heading blocks to group links (e.g. “Private”, “Work”, “Servers”) and assign each heading its own text color and background color.
Custom colors for normal cards
Regular link cards can also get individual text and background colors, independent of the heading. That way you can visually highlight important links or group them by color.
Simple link management in admin mode
In ?mode=admin you can:
add new links (URL + description, optional image/screenshot),
edit existing cards,
adjust colors,
and delete items.
In normal mode (without ?mode=admin) the board is read-only and shows a clean UI for daily use.
Drag & drop sorting
Links and headings can be rearranged via drag & drop. The new order is saved to the JSON file so the layout stays exactly as you arranged it.
Single-file, no-database setup
Everything runs in one PHP file with a single JSON storage file. No database, no installation steps – just upload and start using it. Full source code is included.
<?php
@session_start();
@date_default_timezone_set('Europe/Vienna');
header('X-Content-Type-Options: nosniff');
// ====== Mode (Admin / User) ======
$modeParam = $_GET['mode'] ?? '';
$isAdmin = ($modeParam === 'admin');
$baseUrl = $_SERVER['PHP_SELF'] . ($isAdmin ? '?mode=admin' : '');
// ====== Paths & JSON helpers ======
$DATA_DIR = __DIR__ . '/data';
$UPLOAD_DIR = $DATA_DIR . '/uploads';
$linksFile = $DATA_DIR . '/links.json';
if (!is_dir($DATA_DIR)) {
mkdir($DATA_DIR, 0775, true);
}
if (!is_dir($UPLOAD_DIR)) {
mkdir($UPLOAD_DIR, 0775, true);
}
function load_json($file, $default = []) {
if (!file_exists($file)) return $default;
$raw = @file_get_contents($file);
if ($raw === false || $raw === '') return $default;
$data = json_decode($raw, true);
return is_array($data) ? $data : $default;
}
function save_json($file, $data) {
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
function handle_image_upload($fieldName, $uploadDir) {
if (
!isset($_FILES[$fieldName]) ||
!is_array($_FILES[$fieldName]) ||
($_FILES[$fieldName]['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE
) {
return null;
}
$file = $_FILES[$fieldName];
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
return null;
}
$maxSize = 5 * 1024 * 1024; // 5 MB
if (($file['size'] ?? 0) <= 0 || $file['size'] > $maxSize) {
return null;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$allowed = ['jpg','jpeg','png','gif','webp'];
if (!in_array($ext, $allowed, true)) {
return null;
}
$basename = 'img_' . uniqid('', true) . '.' . $ext;
$targetAbs = rtrim($uploadDir, '/\\') . DIRECTORY_SEPARATOR . $basename;
if (!move_uploaded_file($file['tmp_name'], $targetAbs)) {
return null;
}
return 'data/uploads/' . $basename;
}
function handle_pasted_image($fieldName, $uploadDir) {
if (empty($_POST[$fieldName])) {
return null;
}
$data = $_POST[$fieldName];
$ext = 'png';
if (preg_match('/^data:image\/(\w+);base64,/', $data, $m)) {
$ext = strtolower($m[1]);
$data = substr($data, strpos($data, ',') + 1);
}
$binary = base64_decode($data);
if ($binary === false || strlen($binary) === 0) {
return null;
}
$maxSize = 5 * 1024 * 1024; // 5 MB
if (strlen($binary) > $maxSize) {
return null;
}
$allowed = ['jpg','jpeg','png','gif','webp'];
if (!in_array($ext, $allowed, true)) {
$ext = 'png';
}
$basename = 'clip_' . uniqid('', true) . '.' . $ext;
$targetAbs = rtrim($uploadDir, '/\\') . DIRECTORY_SEPARATOR . $basename;
if (file_put_contents($targetAbs, $binary) === false) {
return null;
}
return 'data/uploads/' . $basename;
}
// ====== Load entries ======
$links = load_json($linksFile, []);
// ====== Actions: delete / edit (admin only) ======
$action = $_GET['action'] ?? null;
$editIndex = null;
$editEntry = null;
if ($action === 'delete') {
if (!$isAdmin) {
header('Location: ' . $baseUrl);
exit;
}
$idx = isset($_GET['idx']) ? (int) $_GET['idx'] : -1;
if (isset($links[$idx])) {
array_splice($links, $idx, 1);
save_json($linksFile, $links);
}
header('Location: ' . $baseUrl);
exit;
}
if ($action === 'edit' && $isAdmin) {
$idx = isset($_GET['idx']) ? (int) $_GET['idx'] : -1;
if (isset($links[$idx])) {
$editIndex = $idx;
$editEntry = $links[$idx];
}
}
// ====== POST: reorder / add / edit (admin only) ======
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$isAdmin) {
// Ignore POST in user mode
header('Location: ' . $baseUrl);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $isAdmin) {
$formMode = $_POST['mode'] ?? 'add'; // add / edit / reorder
// --- Save order (drag & drop) ---
if ($formMode === 'reorder') {
$order = $_POST['order'] ?? [];
$newLinks = [];
foreach ($order as $idx) {
$idx = (int)$idx;
if (isset($links[$idx])) {
$newLinks[] = $links[$idx];
}
}
if (count($newLinks) !== count($links)) {
$used = array_map('intval', $order);
foreach ($links as $k => $entry) {
if (!in_array($k, $used, true)) {
$newLinks[] = $entry;
}
}
}
$links = $newLinks;
save_json($linksFile, $links);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => 'ok']);
exit;
}
$entryType = $_POST['entry_type'] ?? 'link';
$raw = $_POST['links_raw'] ?? '';
$descRaw = trim($_POST['description'] ?? '');
$heading = trim($_POST['heading_title'] ?? '');
$headingColor = trim($_POST['heading_color'] ?? '');
$headingBg = trim($_POST['heading_bg'] ?? '');
$linkColor = trim($_POST['link_color'] ?? '');
$linkBg = trim($_POST['link_bg'] ?? '');
// 1st: screenshot from clipboard
$imagePath = handle_pasted_image('pasted_image', $UPLOAD_DIR);
// 2nd: classic file upload
if ($imagePath === null) {
$imagePath = handle_image_upload('preview_image', $UPLOAD_DIR);
}
if ($formMode === 'edit') {
$idx = isset($_POST['edit_index']) ? (int) $_POST['edit_index'] : -1;
if (isset($links[$idx])) {
$currentType = $links[$idx]['type'] ?? 'link';
$entryTypeForm = $entryType ?: $currentType;
if ($entryTypeForm === 'heading') {
// Save as heading
if ($heading !== '') {
$links[$idx]['type'] = 'heading';
$links[$idx]['title'] = $heading;
$links[$idx]['description'] = $descRaw;
$links[$idx]['color'] = $headingColor !== '' ? $headingColor : null;
$links[$idx]['bg'] = $headingBg !== '' ? $headingBg : null;
unset($links[$idx]['url'], $links[$idx]['image']);
$links[$idx]['updated'] = date('Y-m-d H:i:s');
}
} else {
// Save as link
$lines = preg_split('/\r\n|\r|\n/', $raw);
$url = '';
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '') {
$url = $line;
break;
}
}
if ($url !== '') {
if (!preg_match('!^https?://!i', $url)) {
$url = 'https://' . $url;
}
$links[$idx]['url'] = $url;
}
$links[$idx]['type'] = 'link';
$links[$idx]['description'] = $descRaw;
if ($imagePath !== null) {
$links[$idx]['image'] = $imagePath;
}
// Farben für normale Cards (Link-Einträge)
$links[$idx]['color'] = $linkColor !== '' ? $linkColor : null;
$links[$idx]['bg'] = $linkBg !== '' ? $linkBg : null;
$links[$idx]['updated'] = date('Y-m-d H:i:s');
}
save_json($linksFile, $links);
}
header('Location: ' . $baseUrl);
exit;
} else {
// ===== Add new entries =====
if ($entryType === 'heading') {
if ($heading !== '') {
$links[] = [
'type' => 'heading',
'title' => $heading,
'description' => $descRaw,
'color' => $headingColor !== '' ? $headingColor : null,
'bg' => $headingBg !== '' ? $headingBg : null,
'added' => date('Y-m-d H:i:s'),
];
save_json($linksFile, $links);
}
} else {
$lines = preg_split('/\r\n|\r|\n/', $raw);
foreach ($lines as $line) {
$url = trim($line);
if ($url === '') {
continue;
}
if (!preg_match('!^https?://!i', $url)) {
$url = 'https://' . $url;
}
$exists = false;
foreach ($links as $entry) {
if (($entry['type'] ?? 'link') === 'link' && isset($entry['url']) && $entry['url'] === $url) {
$exists = true;
break;
}
}
if (!$exists) {
$links[] = [
'type' => 'link',
'url' => $url,
'description' => $descRaw,
'image' => $imagePath,
'color' => $linkColor !== '' ? $linkColor : null,
'bg' => $linkBg !== '' ? $linkBg : null,
'added' => date('Y-m-d H:i:s'),
];
}
}
save_json($linksFile, $links);
}
header('Location: ' . $baseUrl);
exit;
}
}
$isEdit = ($editEntry !== null);
$isHeadingEdit = $isEdit && (($editEntry['type'] ?? 'link') === 'heading');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My Quick Launch Board</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #0f172a;
--bg-soft: #020617;
--card-bg: #0b1220;
--card-elevated: #111827;
--border: #1f2937;
--accent: #38bdf8;
--accent-soft: rgba(56, 189, 248, 0.1);
--accent-strong: #0ea5e9;
--danger: #f97373;
--danger-soft: rgba(248, 113, 113, 0.12);
--text: #e5e7eb;
--muted: #9ca3af;
--shadow-soft: 0 18px 45px rgba(15, 23, 42, 0.75);
--radius-lg: 16px;
--radius-pill: 999px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.18), transparent 55%),
radial-gradient(circle at 120% 10%, rgba(59, 130, 246, 0.18), transparent 60%),
linear-gradient(to bottom, var(--bg-soft), var(--bg));
color: var(--text);
min-height: 100vh;
}
.page {
max-width: 1120px;
margin: 0 auto;
padding: 1.5rem 1.25rem 2.5rem;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.app-title-group {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.badge-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0.7rem;
border-radius: 999px;
font-size: 0.73rem;
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(148, 163, 184, 0.4);
color: var(--muted);
}
.badge-pill-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.15);
}
h1 {
font-size: 1.55rem;
letter-spacing: 0.01em;
margin: 0.1rem 0;
}
.subtitle {
color: var(--muted);
margin: 0;
font-size: 0.9rem;
}
.summary-chip {
font-size: 0.8rem;
color: var(--muted);
padding: 0.25rem 0.75rem;
border-radius: var(--radius-pill);
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(55, 65, 81, 0.8);
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.summary-chip span {
color: var(--accent);
font-weight: 600;
}
.flex-wrap {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: flex-start;
}
.card {
background: radial-gradient(circle at top left, rgba(56,189,248,0.14), transparent 55%), var(--card-bg);
border-radius: var(--radius-lg);
border: 1px solid rgba(148, 163, 184, 0.25);
padding: 1.15rem 1.25rem;
box-shadow: var(--shadow-soft);
backdrop-filter: blur(20px);
}
.card-plain {
background: linear-gradient(145deg, rgba(15,23,42,0.95), rgba(15,23,42,0.96));
border-radius: var(--radius-lg);
border: 1px solid rgba(31, 41, 55, 0.9);
padding: 1.15rem 1.25rem;
box-shadow: 0 14px 35px rgba(15, 23, 42, 0.85);
}
.card-input {
flex: 1 1 320px;
min-width: 290px;
position: relative;
overflow: hidden;
}
.card-links {
flex: 2 1 420px;
min-width: 320px;
}
.card-header-line {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.card-header-main {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.card-header-title {
font-size: 1.02rem;
font-weight: 600;
}
.card-header-mode {
font-size: 0.8rem;
color: var(--muted);
padding: 0.15rem 0.6rem;
border-radius: var(--radius-pill);
border: 1px solid rgba(148,163,184,0.4);
background: rgba(15,23,42,0.66);
}
.search-wrapper {
min-width: 170px;
}
.search-input {
width: 100%;
padding: 0.28rem 0.8rem;
border-radius: var(--radius-pill);
border: 1px solid rgba(55,65,81,0.9);
background: rgba(15,23,42,0.95);
color: var(--text);
font-size: 0.8rem;
}
.search-input::placeholder {
color: rgba(148,163,184,0.75);
}
.search-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-soft);
background: rgba(15,23,42,1);
}
textarea {
width: 100%;
resize: vertical;
padding: 0.65rem 0.7rem;
border-radius: 10px;
border: 1px solid rgba(55, 65, 81, 0.9);
background: rgba(15, 23, 42, 0.85);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.86rem;
}
textarea::placeholder { color: rgba(148, 163, 184, 0.7); }
textarea:focus {
outline: 0;
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-soft), 0 0 0 1px rgba(15,23,42,0.8);
background: rgba(15, 23, 42, 0.98);
}
.input-label {
font-size: 0.82rem;
font-weight: 600;
display: block;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
letter-spacing: 0.02em;
text-transform: uppercase;
color: rgba(209, 213, 219, 0.9);
}
.input-sub {
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 0.3rem;
}
input[type="file"],
input[type="color"] {
display: block;
font-size: 0.8rem;
margin-top: 0.2rem;
color: var(--muted);
}
input[type="color"] {
padding: 0;
border-radius: 6px;
border: 1px solid rgba(55, 65, 81, 0.9);
background: rgba(15,23,42,0.9);
height: 32px;
width: 70px;
}
input[type="file"] {
width: 100%;
}
input[type="file"]::file-selector-button {
border: 1px solid rgba(55, 65, 81, 0.9);
border-radius: var(--radius-pill);
padding: 0.35rem 0.8rem;
margin-right: 0.7rem;
background: rgba(15, 23, 42, 0.9);
color: var(--text);
cursor: pointer;
font-size: 0.78rem;
}
input[type="file"]::file-selector-button:hover {
border-color: var(--accent);
background: rgba(15, 23, 42, 1);
}
.paste-area {
border-radius: 10px;
border: 1px dashed rgba(75, 85, 99, 0.9);
background: rgba(15, 23, 42, 0.75);
padding: 0.6rem 0.7rem;
min-height: 68px;
font-size: 0.8rem;
color: var(--muted);
cursor: text;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.paste-area:focus {
outline: 0;
border-color: var(--accent);
background: rgba(15, 23, 42, 0.98);
box-shadow: 0 0 0 1px var(--accent-soft);
color: var(--text);
}
.hint {
font-size: 0.78rem;
color: var(--muted);
margin: 0.35rem 0 0.4rem;
}
.btn-row {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
margin-top: 0.7rem;
}
.btn {
border-radius: var(--radius-pill);
padding: 0.4rem 1.2rem;
font-size: 0.86rem;
border: 1px solid transparent;
cursor: pointer;
background: radial-gradient(circle at top left, var(--accent-strong), var(--accent));
color: #0b1220;
font-weight: 600;
letter-spacing: 0.02em;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.btn::before { content: "+"; font-size: 0.85rem; }
.btn-secondary {
background: rgba(15, 23, 42, 0.95);
color: var(--muted);
border-color: rgba(75, 85, 99, 0.9);
}
.btn-secondary::before { content: "⟲"; font-size: 0.9rem; }
.btn:hover {
filter: brightness(1.05);
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.25);
}
.btn-secondary:hover {
background: rgba(15, 23, 42, 1);
border-color: var(--accent);
color: #e5e7eb;
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.9rem;
margin-top: 0.5rem;
}
.link-item {
border-radius: 14px;
border: 1px solid rgba(55,65,81,0.9);
padding: 0.6rem 0.7rem 0.65rem;
background: radial-gradient(circle at top left, rgba(56,189,248,0.13), transparent 70%), var(--card-elevated);
display: flex;
flex-direction: column;
gap: 0.25rem;
position: relative;
overflow: hidden;
cursor: grab;
}
.link-item.is-dragging {
opacity: 0.6;
cursor: grabbing;
border-color: var(--accent);
background: rgba(15,23,42,0.95);
transform: scale(0.98);
}
.heading-item {
grid-column: 1 / -1;
background: linear-gradient(90deg, rgba(56,189,248,0.12), rgba(56,189,248,0.02));
border-style: dashed;
}
.heading-title {
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #e0f2fe;
}
.heading-desc {
font-size: 0.8rem;
color: var(--muted);
margin-top: 0.1rem;
}
.link-top-row {
display: flex;
justify-content: space-between;
gap: 0.3rem;
align-items: baseline;
}
.link-url {
font-size: 0.85rem;
line-height: 1.3;
word-break: break-all;
color: var(--accent-soft);
}
.link-url a {
color: var(--accent);
text-decoration: none;
}
.link-url a:hover { text-decoration: underline; }
.link-meta {
font-size: 0.72rem;
color: var(--muted);
}
.link-updated-pill {
display: inline-block;
padding: 0.05rem 0.45rem;
border-radius: 999px;
font-size: 0.68rem;
border: 1px solid rgba(148, 163, 184, 0.6);
color: rgba(209, 213, 219, 0.8);
background: rgba(15, 23, 42, 0.9);
}
.link-desc {
font-size: 0.8rem;
margin-top: 0.1rem;
color: #e5e7eb;
}
.link-preview { margin-top: 0.25rem; }
.link-preview img {
max-width: 100%;
max-height: 140px;
display: block;
border-radius: 10px;
border: 1px solid rgba(55, 65, 81, 0.9);
object-fit: cover;
}
.link-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
margin-top: 0.2rem;
}
.link-btn {
border-radius: 999px;
padding: 0.2rem 0.7rem;
font-size: 0.72rem;
border: 1px solid rgba(75,85,99,0.9);
background: rgba(15,23,42,0.85);
color: var(--muted);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.link-btn:hover {
border-color: var(--accent);
color: #e5e7eb;
background: rgba(15,23,42,1);
}
.link-btn span.icon-edit::before { content: "✎"; font-size: 0.7rem; }
.link-btn span.icon-delete::before { content: "âś•"; font-size: 0.7rem; }
.link-btn-danger {
border-color: rgba(248,113,113,0.9);
background: var(--danger-soft);
color: #fecaca;
}
.link-btn-danger:hover {
background: rgba(239, 68, 68, 0.28);
border-color: var(--danger);
color: #fee2e2;
}
.empty {
font-size: 0.85rem;
color: var(--muted);
margin-top: 0.7rem;
}
.section-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(148,163,184,0.9);
margin-bottom: 0.2rem;
}
.current-image-note {
font-size: 0.78rem;
color: var(--muted);
margin-top: 0.3rem;
}
.current-image-wrapper img {
max-width: 100%;
max-height: 120px;
border-radius: 10px;
border: 1px solid rgba(55,65,81,0.9);
margin-top: 0.25rem;
display: block;
}
.link-preview-inline {
margin-top: 0.3rem;
}
.link-preview-inline img {
max-width: 100%;
max-height: 80px;
border-radius: 8px;
border: 1px solid rgba(55,65,81,0.9);
display: block;
}
.type-toggle {
display: inline-flex;
gap: 0.6rem;
font-size: 0.8rem;
margin: 0.2rem 0 0.5rem;
color: var(--muted);
}
.type-toggle label {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
padding: 0.15rem 0.6rem;
border-radius: 999px;
border: 1px solid rgba(55,65,81,0.9);
background: rgba(15,23,42,0.9);
}
.type-toggle input { accent-color: var(--accent); }
.type-toggle label.active {
border-color: var(--accent);
background: rgba(56,189,248,0.15);
color: #e5e7eb;
}
.color-row {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-top: 0.3rem;
}
.color-row span {
font-size: 0.78rem;
color: var(--muted);
}
@media (max-width: 700px) {
.app-header {
flex-direction: column;
align-items: flex-start;
}
.card-header-line {
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
.search-wrapper {
width: 100%;
}
}
</style>
</head>
<body>
<div class="page">
<header class="app-header">
<div class="app-title-group">
<div class="badge-pill">
<span class="badge-pill-dot"></span>
<span>Quick Launch</span>
<span>·</span>
<span>Local JSON</span>
</div>
<h1>My Quick Launch Board</h1>
<p class="subtitle">
Collect links and headings, group them, add small previews – and optionally manage everything in admin mode.
</p>
</div>
<div>
<div class="summary-chip">
<span><?php echo count($links); ?></span> entries
</div>
</div>
</header>
<div class="flex-wrap">
<?php if ($isAdmin): ?>
<!-- Input card: admin mode only -->
<div class="card card-input">
<div class="card-header-line">
<div class="card-header-title">
<?php echo $isEdit ? 'Edit entry' : 'Add entry'; ?>
</div>
<div class="card-header-mode">
Mode: Admin
</div>
</div>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="mode" value="<?php echo $isEdit ? 'edit' : 'add'; ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="edit_index" value="<?php echo (int)$editIndex; ?>">
<?php endif; ?>
<label class="input-label">Entry type</label>
<div class="type-toggle" id="typeToggle">
<label class="<?php echo !$isHeadingEdit ? 'active' : ''; ?>">
<input type="radio" name="entry_type" value="link" <?php echo !$isHeadingEdit ? 'checked' : ''; ?>> Link
</label>
<label class="<?php echo $isHeadingEdit ? 'active' : ''; ?>">
<input type="radio" name="entry_type" value="heading" <?php echo $isHeadingEdit ? 'checked' : ''; ?>> Heading
</label>
</div>
<!-- Link fields -->
<div id="link_fields" style="<?php echo $isHeadingEdit ? 'display:none;' : ''; ?>">
<label for="links_raw" class="input-label">URL(s)</label>
<p class="input-sub">
<?php if ($isEdit && !$isHeadingEdit): ?>
When editing, only the first non-empty line is used.
<?php else: ?>
Each line is saved as a separate link.
<?php endif; ?>
</p>
<textarea
id="links_raw"
name="links_raw"
placeholder="<?php echo $isEdit
? 'https://example.com/my-link'
: "One URL per line\nExamples:\nhttps://bergland.info\nunterkunft-sillian.com/zimmer.html"; ?>"
><?php
if ($isEdit && !$isHeadingEdit) {
echo htmlspecialchars($editEntry['url'] ?? '', ENT_QUOTES, 'UTF-8');
}
?></textarea>
<p class="hint">
If no <code>http://</code> or <code>https://</code> is given, <code>https://</code> will be added automatically.
</p>
</div>
<!-- Heading fields -->
<div id="heading_fields" style="<?php echo $isHeadingEdit ? '' : 'display:none;'; ?>">
<label for="heading_title" class="input-label">Heading title</label>
<p class="input-sub">
Use headings to group links, e.g. “Work / SEO”, “Private”, “Project XYZ”.
</p>
<textarea
id="heading_title"
name="heading_title"
placeholder="Group title, e.g. “Project: Pilgrim Route”"
><?php
if ($isHeadingEdit) {
echo htmlspecialchars($editEntry['title'] ?? '', ENT_QUOTES, 'UTF-8');
}
?></textarea>
<?php
$defaultHeadingColor = '#e0f2fe';
$defaultHeadingBg = '#1f2937';
$existingColor = $isHeadingEdit ? ($editEntry['color'] ?? $defaultHeadingColor) : $defaultHeadingColor;
$existingBg = $isHeadingEdit ? ($editEntry['bg'] ?? $defaultHeadingBg) : $defaultHeadingBg;
?>
<div class="color-row">
<div>
<span>Text color</span>
<input type="color" id="heading_color" name="heading_color"
value="<?php echo htmlspecialchars($existingColor, ENT_QUOTES, 'UTF-8'); ?>">
</div>
<div>
<span>Background color</span>
<input type="color" id="heading_bg" name="heading_bg"
value="<?php echo htmlspecialchars($existingBg, ENT_QUOTES, 'UTF-8'); ?>">
</div>
</div>
</div>
<label for="description" class="input-label">Description (optional)</label>
<textarea
id="description"
name="description"
placeholder="Short description – e.g. purpose of the page or group."
><?php
if ($isEdit) {
echo htmlspecialchars($editEntry['description'] ?? '', ENT_QUOTES, 'UTF-8');
}
?></textarea>
<!-- Image block + card colors: only for links -->
<div id="image_block" style="<?php echo $isHeadingEdit ? 'display:none;' : ''; ?>">
<?php
$defaultLinkColor = '#e5e7eb';
$defaultLinkBg = '#111827';
if ($isEdit && !$isHeadingEdit) {
$existingLinkColor = $editEntry['color'] ?? $defaultLinkColor;
$existingLinkBg = $editEntry['bg'] ?? $defaultLinkBg;
} else {
$existingLinkColor = $defaultLinkColor;
$existingLinkBg = $defaultLinkBg;
}
?>
<label for="preview_image" class="input-label">Preview image (file upload, optional)</label>
<p class="input-sub">
Small logo, photo or mini screenshot – max. ~5 MB.
</p>
<input type="file" id="preview_image" name="preview_image" accept="image/*">
<p class="hint">
Allowed formats: JPG, PNG, GIF, WEBP.
</p>
<label class="input-label">Screenshot from clipboard (optional)</label>
<p class="input-sub">
Click the field, then press <strong>Ctrl+V</strong> / <strong>Cmd+V</strong> to paste a screenshot.
</p>
<div id="paste_area" class="paste-area" tabindex="0">
Click here and then use <strong>Ctrl+V</strong> / <strong>Cmd+V</strong> to paste a screenshot.
</div>
<input type="hidden" name="pasted_image" id="pasted_image">
<div class="link-preview-inline" id="paste_preview" style="display:none; margin-top:0.35rem;">
<img id="paste_preview_img" alt="Screenshot preview">
</div>
<p class="hint" id="paste_info">
If a screenshot is pasted here, it has priority over the file upload.
</p>
<label class="input-label">Card colors (optional)</label>
<div class="color-row">
<div>
<span>Text color</span>
<input type="color" id="link_color" name="link_color"
value="<?php echo htmlspecialchars($existingLinkColor, ENT_QUOTES, 'UTF-8'); ?>">
</div>
<div>
<span>Background color</span>
<input type="color" id="link_bg" name="link_bg"
value="<?php echo htmlspecialchars($existingLinkBg, ENT_QUOTES, 'UTF-8'); ?>">
</div>
</div>
<p class="hint">
These colors apply to this link card. Leave as-is for the default style.
</p>
</div>
<?php if ($isEdit && !$isHeadingEdit && !empty($editEntry['image'])): ?>
<div class="current-image-note">
Current image (kept if no new image is uploaded or pasted):
<div class="current-image-wrapper">
<img src="<?php echo htmlspecialchars($editEntry['image'], ENT_QUOTES, 'UTF-8'); ?>" alt="Current preview">
</div>
</div>
<?php endif; ?>
<div class="btn-row">
<button type="reset" class="btn-secondary btn" onclick="clearPastePreview()">
Reset form
</button>
<button type="submit" class="btn">
<?php echo $isEdit ? 'Save changes' : 'Save entry'; ?>
</button>
</div>
</form>
</div>
<?php endif; ?>
<!-- Links / headings card (visible in both modes) -->
<div class="card-plain card-links">
<div class="card-header-line">
<div class="card-header-main">
<div class="card-header-title">Saved entries</div>
<div class="section-label">
<?php echo $isAdmin ? 'Drag & drop sortable (admin)' : 'Read-only view'; ?>
</div>
</div>
<div class="search-wrapper">
<input
type="search"
id="searchInput"
class="search-input"
placeholder="Search…"
autocomplete="off"
>
</div>
</div>
<?php if (empty($links)): ?>
<p class="empty">
No entries yet.
<?php echo $isAdmin ? 'Use admin mode to add links or headings.' : 'Configure entries via ?mode=admin.'; ?>
</p>
<?php else: ?>
<div class="links-grid">
<?php foreach ($links as $i => $entry): ?>
<?php
$entryType = $entry['type'] ?? 'link';
$added = htmlspecialchars($entry['added'] ?? '', ENT_QUOTES, 'UTF-8');
$updated = htmlspecialchars($entry['updated'] ?? '', ENT_QUOTES, 'UTF-8');
$desc = htmlspecialchars($entry['description'] ?? '', ENT_QUOTES, 'UTF-8');
?>
<?php if ($entryType === 'heading'): ?>
<?php
$title = htmlspecialchars($entry['title'] ?? 'Heading', ENT_QUOTES, 'UTF-8');
$hColor = $entry['color'] ?? null;
$hBg = $entry['bg'] ?? null;
$style = '';
if ($hColor) {
$style .= 'color:' . htmlspecialchars($hColor, ENT_QUOTES, 'UTF-8') . ';';
}
if ($hBg) {
$style .= 'background:' . htmlspecialchars($hBg, ENT_QUOTES, 'UTF-8') . ';';
}
?>
<div class="link-item heading-item" data-index="<?php echo (int)$i; ?>"
<?php if ($style !== ''): ?>style="<?php echo $style; ?>"<?php endif; ?>>
<div class="heading-title">
<?php echo $title; ?>
</div>
<?php if ($desc !== ''): ?>
<div class="heading-desc"><?php echo nl2br($desc); ?></div>
<?php endif; ?>
<?php if ($added !== ''): ?>
<div class="link-meta">
Created at <?php echo $added; ?>
<?php if ($updated !== ''): ?>
· Updated at <?php echo $updated; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($isAdmin): ?>
<div class="link-actions">
<a href="<?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8'); ?>&action=edit&idx=<?php echo (int)$i; ?>" class="link-btn">
<span class="icon-edit"></span>
Edit
</a>
<button type="button" class="link-btn link-btn-danger" onclick="confirmDelete(<?php echo (int)$i; ?>)">
<span class="icon-delete"></span>
Delete
</button>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<?php
$url = htmlspecialchars($entry['url'] ?? '', ENT_QUOTES, 'UTF-8');
$img = $entry['image'] ?? null;
$linkColor = $entry['color'] ?? null;
$linkBg = $entry['bg'] ?? null;
$cardStyle = '';
if ($linkColor) {
$cardStyle .= 'color:' . htmlspecialchars($linkColor, ENT_QUOTES, 'UTF-8') . ';';
}
if ($linkBg) {
$cardStyle .= 'background:' . htmlspecialchars($linkBg, ENT_QUOTES, 'UTF-8') . ';';
}
?>
<div class="link-item" data-index="<?php echo (int)$i; ?>"
<?php if ($cardStyle !== ''): ?>style="<?php echo $cardStyle; ?>"<?php endif; ?>>
<div class="link-top-row">
<div class="link-url">
<a href="<?php echo $url; ?>" target="_blank" rel="noopener noreferrer"><?php echo $url; ?></a>
</div>
<?php if ($updated !== ''): ?>
<span class="link-updated-pill">updated</span>
<?php endif; ?>
</div>
<?php if ($added !== ''): ?>
<div class="link-meta">
Created at <?php echo $added; ?>
<?php if ($updated !== ''): ?>
· Updated at <?php echo $updated; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($desc !== ''): ?>
<div class="link-desc"><?php echo nl2br($desc); ?></div>
<?php endif; ?>
<?php if (!empty($img)): ?>
<div class="link-preview">
<img src="<?php echo htmlspecialchars($img, ENT_QUOTES, 'UTF-8'); ?>" alt="Preview">
</div>
<?php endif; ?>
<?php if ($isAdmin): ?>
<div class="link-actions">
<a href="<?php echo htmlspecialchars($baseUrl, ENT_QUOTES, 'UTF-8'); ?>&action=edit&idx=<?php echo (int)$i; ?>" class="link-btn">
<span class="icon-edit"></span>
Edit
</a>
<button type="button" class="link-btn link-btn-danger" onclick="confirmDelete(<?php echo (int)$i; ?>)">
<span class="icon-delete"></span>
Delete
</button>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script>
const IS_ADMIN = <?php echo $isAdmin ? 'true' : 'false'; ?>;
const BASE_URL = '<?php echo htmlspecialchars($_SERVER["PHP_SELF"], ENT_QUOTES, "UTF-8"); ?>';
// Type toggle (link / heading) – admin only
(function() {
if (!IS_ADMIN) return;
const typeToggle = document.getElementById('typeToggle');
const linkFields = document.getElementById('link_fields');
const headingFields = document.getElementById('heading_fields');
const imageBlock = document.getElementById('image_block');
if (!typeToggle) return;
typeToggle.addEventListener('change', function(e) {
const value = (e.target && e.target.value) || 'link';
const labels = typeToggle.querySelectorAll('label');
labels.forEach(l => l.classList.remove('active'));
if (e.target && e.target.parentElement && e.target.parentElement.tagName === 'LABEL') {
e.target.parentElement.classList.add('active');
}
if (value === 'heading') {
if (linkFields) linkFields.style.display = 'none';
if (imageBlock) imageBlock.style.display = 'none';
if (headingFields) headingFields.style.display = '';
} else {
if (linkFields) linkFields.style.display = '';
if (imageBlock) imageBlock.style.display = '';
if (headingFields) headingFields.style.display = 'none';
}
});
})();
// Screenshot paste handling – admin only
(function() {
if (!IS_ADMIN) return;
const pasteArea = document.getElementById('paste_area');
const hiddenInput = document.getElementById('pasted_image');
const previewBox = document.getElementById('paste_preview');
const previewImg = document.getElementById('paste_preview_img');
const info = document.getElementById('paste_info');
if (!pasteArea) return;
pasteArea.addEventListener('paste', function(e) {
const clipboard = e.clipboardData || window.clipboardData;
if (!clipboard || !clipboard.items) return;
const items = clipboard.items;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type && item.type.indexOf('image') !== -1) {
const file = item.getAsFile();
if (!file) continue;
const reader = new FileReader();
reader.onload = function(ev) {
hiddenInput.value = ev.target.result;
if (previewImg) {
previewImg.src = ev.target.result;
}
if (previewBox) {
previewBox.style.display = 'block';
}
if (info) {
info.textContent = 'Screenshot captured. It will be used when saving (takes priority over file upload).';
}
};
reader.readAsDataURL(file);
e.preventDefault();
break;
}
}
});
})();
function clearPastePreview() {
if (!IS_ADMIN) return;
const hiddenInput = document.getElementById('pasted_image');
const previewBox = document.getElementById('paste_preview');
const previewImg = document.getElementById('paste_preview_img');
const info = document.getElementById('paste_info');
if (hiddenInput) hiddenInput.value = '';
if (previewImg) previewImg.src = '';
if (previewBox) previewBox.style.display = 'none';
if (info) {
info.textContent = 'If a screenshot is pasted here, it has priority over the file upload.';
}
}
function confirmDelete(idx) {
if (!IS_ADMIN) return;
if (confirm('Really delete this entry?')) {
const url = BASE_URL + '?mode=admin&action=delete&idx=' + encodeURIComponent(idx);
window.location.href = url;
}
}
// Drag & drop sorting – admin only
(function() {
if (!IS_ADMIN) return;
const container = document.querySelector('.links-grid');
if (!container) return;
container.querySelectorAll('.link-item').forEach(item => {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', e => {
item.classList.add('is-dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
try {
e.dataTransfer.setData('text/plain', item.dataset.index || '');
} catch (err) {}
}
});
item.addEventListener('dragend', () => {
item.classList.remove('is-dragging');
saveNewOrder(container);
});
});
container.addEventListener('dragover', e => {
e.preventDefault();
const afterEl = getDragAfterElement(container, e.clientY);
const dragging = container.querySelector('.is-dragging');
if (!dragging) return;
if (afterEl == null) {
container.appendChild(dragging);
} else {
container.insertBefore(dragging, afterEl);
}
});
function getDragAfterElement(container, y) {
const items = [...container.querySelectorAll('.link-item:not(.is-dragging)')];
return items.reduce(
(closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
} else {
return closest;
}
},
{ offset: Number.NEGATIVE_INFINITY, element: null }
).element;
}
function saveNewOrder(container) {
const items = container.querySelectorAll('.link-item');
const order = [];
items.forEach(item => {
if (item.dataset.index !== undefined) {
order.push(item.dataset.index);
}
});
if (order.length === 0) return;
const params = new URLSearchParams();
params.append('mode', 'reorder');
order.forEach(idx => params.append('order[]', idx));
const reorderUrl = BASE_URL + '?mode=admin';
fetch(reorderUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
}).catch(err => {
console.error('Reorder failed', err);
});
}
})();
// Client-side search filter + autofocus
(function() {
const input = document.getElementById('searchInput');
if (!input) return;
// Fokus beim Start auf die Suche legen
input.focus();
if (input.select) {
input.select();
}
const container = document.querySelector('.links-grid');
if (!container) return;
input.addEventListener('input', function() {
const q = this.value.trim().toLowerCase();
const items = container.querySelectorAll('.link-item');
items.forEach(item => {
const text = item.innerText.toLowerCase();
if (!q || text.indexOf(q) !== -1) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
});
})();
</script>
</body>
</html>