FiveTech Support Forums

FiveWin / Harbour / xBase community
Board index mod_harbour Quick Launch Board (Full source code included)
Posts: 6984
Joined: Fri Oct 07, 2005 07:07 PM
Quick Launch Board (Full source code included)
Posted: Thu Nov 13, 2025 07:54 AM

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
Foreword

Especially 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.

  1. 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=admin

User mode – clean read-only launch board
→ startseite.php

No database, no external libraries, just plain PHP/HTML/CSS/JS.

  1. 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

  1. Admin mode vs. User mode
    Admin mode

Open:

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.

  1. 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.

  1. 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.

  1. 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().

  1. 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).

  1. 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.

  1. 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.

  1. 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 &amp; 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'); ?>&amp;action=edit&amp;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'); ?>&amp;action=edit&amp;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>
Posts: 6984
Joined: Fri Oct 07, 2005 07:07 PM
Re: Quick Launch Board
Posted: Thu Nov 13, 2025 07:11 PM

Continue the discussion