The whole launch board is implemented in a single PHP/HTML/JS file – no installation, no database, no external libraries required. If you’d like to look at or reuse the source code, just leave a comment here and I’ll set up a download.
Why this launch board is so simple and useful
Key features (for busy developers)
- Single file – everything lives in one
startseite.php , easy to copy or drop into any project. - No database – all data is stored in a small JSON file on disk.
- Ready to use – upload the file, call it in your browser, done. No install, no Composer, no external libraries.
- Drag & drop sorting (admin mode) – rearrange links and headings directly on the board.
- Online editing (admin mode) – add, edit and delete entries from your browser without touching the code.
************************************************************************************************************************************************************************************


Quick Launch Board – User Guide
ForewordEspecially for developers it’s very practical to have a “launch board” for all the files, tools and URLs you touch every day.
This little script gives you exactly that:You can group entries with headings
Describe each link
And add a screenshot or small preview image – because a picture often says more than a thousand words.
- What the script does
The script startseite.php is a small, self-contained “launch board”:
Stores everything in a simple JSON file (data/links.json)
Supports two entry types:
Link – a URL + description + optional preview image
Heading – a group title with optional description and colors
Has two views:
Admin mode – manage entries, drag & drop, upload screenshots
→ startseite.php?mode=adminUser mode – clean read-only launch board
→ startseite.phpNo database, no external libraries, just plain PHP/HTML/CSS/JS.
- Installation
Copy the full code into a file named e.g. startseite.php.
Place it on a PHP-enabled web server.
Make sure PHP can write to the directory:
The script automatically creates:
/data
/data/uploads
Point your browser to:
https://your-server/startseite.php?mode=admin → first setup
- Admin mode vs. User mode
Admin modeOpen:
startseite.php?mode=admin
In this mode you can:
Add new entries (links or headings)
Edit existing entries
Delete entries
Reorder entries via drag & drop
Attach file uploads or clipboard screenshots (for links)
All write operations (add/edit/delete/reorder) are only processed in admin mode.
User mode
Open:
startseite.php
In this mode you get a clean board view:
Headings and links are displayed as cards
No edit/delete buttons
No drag & drop
No form
Perfect as a personal “start page” in your browser.
- Adding links
In admin mode:
Make sure Entry type = Link is selected.
In URL(s):
Enter one URL per line.
On add, each non-empty line becomes a separate link.
On edit, only the first non-empty line is used.
Click Save entry.
Notes:
If you type example.com without protocol, the script will automatically store https://example.com.
Duplicate URLs (same URL, same type “link”) are skipped when adding.
- Adding headings (groups)
Headings allow you to visually group related links (e.g. “Work / CRM”, “Testing”, “Personal”).
In admin mode:
Select Entry type = Heading.
In Heading title, enter the group title.
(Optional) Add a Description below.
Choose Text color and Background color:
Via color pickers (<input type="color">)
Colors are stored per heading in JSON.
Click Save entry.
In the board:
Headings span the full width of the grid.
Link cards below naturally “belong” to that heading.
You can drag headings up/down like any other card to re-group visually.
- Descriptions
Both entry types support a description:
For links:
Use it as a short tooltip/notes area (e.g. “Staging backend – use test logins only”).
For headings:
Use it to explain the area (e.g. “Daily tools I need for SEO work”).
Descriptions support simple line breaks; they’re rendered with nl2br().
- Preview images & screenshots (links only)
For link entries you can attach a small preview image.
Option A – File upload
In admin mode, with Entry type = Link:
Use Preview image (file upload):
Select an image (JPG/PNG/GIF/WEBP, up to 5 MB).
When you save the entry, the file is stored in data/uploads/… and the card shows a small preview.
Option B – Screenshot from clipboard
Make a screenshot (e.g. PrintScreen or OS shortcut).
In admin mode:
Click the Screenshot from clipboard area.
Press Ctrl+V / Cmd+V.
The script:
Reads the image from the clipboard via JavaScript
Encodes it as Base64 and posts it to PHP
PHP decodes and stores it as an image file in data/uploads/…
If both file upload and screenshot are provided, the screenshot wins (has priority).
- Editing and deleting entries (admin)
In the cards overview (admin mode):
Edit
Click Edit on a card.
The form is populated with this entry’s data.
Change type (link/heading), text, colors or image.
Click Save changes.
Delete
Click Delete.
Confirm the dialog.
The entry is removed from links.json.
- Drag & drop sorting (admin)
In admin mode, every card (link or heading) is draggable:
Click and hold a card.
Drag it to the desired position.
Release the mouse button.
The script:
Reorders the cards in the DOM
Sends the new index order via POST (mode=reorder)
Saves the new sequence back into links.json
In user mode, drag & drop is disabled – cards are purely static.
- Data storage & backup
All content is stored in:
data/links.json
data/uploads/… (for images/screenshots)For backup:
Regularly copy the entire data directory.
To move the board to a new server, you only need:
startseite.php
The data directory
<?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>