diff --git a/icons/128.png b/icons/128.png new file mode 100644 index 0000000..66f8d88 Binary files /dev/null and b/icons/128.png differ diff --git a/icons/16.png b/icons/16.png new file mode 100644 index 0000000..f030eda Binary files /dev/null and b/icons/16.png differ diff --git a/icons/48.png b/icons/48.png new file mode 100644 index 0000000..e32df2e Binary files /dev/null and b/icons/48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..165d799 --- /dev/null +++ b/manifest.json @@ -0,0 +1,13 @@ +{ + "manifest_version": 3, + "name": "LabLinks", + "version": "1.0.0", + "description": "Retro-Launcher für deine HomeLab WebUIs.", + "permissions": ["storage"], + "action": { + "default_popup": "popup.html", + "default_title": "LabLinks", + "default_icon": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" } + }, + "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" } +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..ba8f491 --- /dev/null +++ b/popup.css @@ -0,0 +1,107 @@ +:root { + --bg: #0b0f0c; + --panel: #101812; + --fg: #ffb000; + --fg-dim: #8a6200; + --green: #39ff14; + --line: #3a2a00; + --font: "Courier New", "Lucida Console", ui-monospace, monospace; +} + +* { box-sizing: border-box; margin: 0; } + +body { + width: 340px; + min-height: 120px; + background: var(--bg); + color: var(--fg); + font: 14px/1.4 var(--font); +} + +.crt { + position: relative; + padding: 12px; + border: 3px double var(--fg); + margin: 4px; + background: var(--panel); + text-shadow: 0 0 4px rgba(255, 176, 0, .55); +} +/* Scanlines */ +.crt::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient(to bottom, rgba(0,0,0,.18) 0 1px, transparent 1px 3px); +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 2px dashed var(--line); +} +h1 { font-size: 20px; letter-spacing: 3px; color: var(--green); text-shadow: 0 0 6px rgba(57,255,20,.6); } +h1 span { color: var(--fg); } +.cursor { + display: inline-block; width: 9px; height: 16px; margin-left: 4px; + background: var(--green); vertical-align: -2px; + animation: blink 1s steps(2, start) infinite; +} +@keyframes blink { to { visibility: hidden; } } + +button { + font: inherit; font-size: 12px; color: var(--fg); background: none; + border: 0; cursor: pointer; padding: 2px 4px; text-shadow: inherit; +} +button:hover, button:focus-visible { background: var(--fg); color: var(--bg); outline: none; text-shadow: none; } +button.primary { color: var(--green); } +button.primary:hover { background: var(--green); color: var(--bg); } + +input[type="text"], input:not([type]), #form input { + width: 100%; font: inherit; color: var(--green); background: var(--bg); + border: 2px solid var(--line); padding: 6px 8px; outline: none; margin-bottom: 8px; +} +input:focus { border-color: var(--fg); } +input::placeholder { color: var(--fg-dim); } +#search { margin-bottom: 10px; } + +ul { list-style: none; padding: 0; max-height: 320px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--fg) var(--bg); } + +li { + display: flex; align-items: center; gap: 4px; margin-bottom: 6px; +} +li a { + flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; + padding: 7px 9px; color: var(--fg); text-decoration: none; + border: 2px solid var(--line); background: var(--bg); + box-shadow: 3px 3px 0 var(--line); + transition: transform .06s, box-shadow .06s; +} +li a:hover, li a:focus-visible { + border-color: var(--fg); color: var(--green); outline: none; + transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--fg-dim); +} +li a:hover .name::before, li a:focus-visible .name::before { content: "> "; } +.ico { font-size: 18px; width: 22px; text-align: center; flex: none; } +.txt { min-width: 0; display: flex; flex-direction: column; } +.name { font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.url { font-size: 11px; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.edit-btns { display: none; flex-direction: column; } +body.editing .edit-btns { display: flex; } +.edit-btns button { padding: 0 4px; font-size: 11px; } +.edit-btns .del { color: #ff4b3e; } + +#empty { color: var(--fg-dim); margin: 6px 0 10px; } + +#form { border-top: 2px dashed var(--line); padding-top: 10px; margin-top: 4px; } +.row { display: flex; gap: 6px; } +.row input:first-child:not(:only-child) { width: 52px; text-align: center; flex: none; } +.row button { margin-bottom: 6px; } + +footer { display: flex; gap: 4px; align-items: center; margin-top: 4px; min-height: 22px; } +#count { margin-left: auto; font-size: 11px; color: var(--fg-dim); } +[hidden] { display: none !important; } diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..cdb4a86 --- /dev/null +++ b/popup.html @@ -0,0 +1,44 @@ + + + + + LabLinks + + + +
+
+

LABLINKS

+
+ +
+
+ + + + + + + + + +
+ + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..c80baf1 --- /dev/null +++ b/popup.js @@ -0,0 +1,145 @@ +const $ = (id) => document.getElementById(id); +const store = chrome.storage.sync; + +let links = []; +let editing = false; +let editId = null; + +const uid = () => Math.random().toString(36).slice(2, 10); + +function normalizeUrl(raw) { + raw = raw.trim(); + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = "http://" + raw; + try { return new URL(raw).href; } catch { return null; } +} + +async function load() { + const data = await store.get("links"); + links = data.links || []; + render(); +} +const save = () => store.set({ links }); + +function render() { + const q = $("search").value.trim().toLowerCase(); + const list = $("list"); + list.textContent = ""; + + const shown = links.filter((l) => !q || (l.name + " " + l.url).toLowerCase().includes(q)); + for (const l of shown) { + const li = document.createElement("li"); + + const a = document.createElement("a"); + a.href = l.url; + a.target = "_blank"; + a.rel = "noopener"; + a.innerHTML = ''; + a.querySelector(".ico").textContent = l.icon || "▣"; + a.querySelector(".name").textContent = l.name; + a.querySelector(".url").textContent = l.url.replace(/^https?:\/\//, "").replace(/\/$/, ""); + if (editing) a.addEventListener("click", (e) => e.preventDefault()); + li.append(a); + + const eb = document.createElement("div"); + eb.className = "edit-btns"; + eb.append( + btn("[✎]", () => openForm(l)), + btn("[↑]", () => move(l.id, -1)), + btn("[↓]", () => move(l.id, 1)), + Object.assign(btn("[✕]", () => remove(l.id)), { className: "del" }) + ); + li.append(eb); + list.append(li); + } + + $("empty").hidden = links.length > 0 || !!editId; + $("count").textContent = links.length ? `${links.length} Einträge` : ""; + document.body.classList.toggle("editing", editing); + for (const id of ["add", "export", "import"]) $(id).hidden = !editing; + $("editToggle").textContent = editing ? "[FERTIG]" : "[EDIT]"; +} + +function btn(label, fn) { + const b = document.createElement("button"); + b.textContent = label; + b.addEventListener("click", fn); + return b; +} + +async function move(id, dir) { + const i = links.findIndex((l) => l.id === id); + const j = i + dir; + if (j < 0 || j >= links.length) return; + [links[i], links[j]] = [links[j], links[i]]; + await save(); + render(); +} + +async function remove(id) { + links = links.filter((l) => l.id !== id); + await save(); + render(); +} + +function openForm(link) { + editId = link ? link.id : "new"; + $("fIcon").value = link?.icon || ""; + $("fName").value = link?.name || ""; + $("fUrl").value = link?.url || ""; + $("form").hidden = false; + $("fName").focus(); +} +function closeForm() { + editId = null; + $("form").hidden = true; + render(); +} + +$("form").addEventListener("submit", async (e) => { + e.preventDefault(); + const url = normalizeUrl($("fUrl").value); + if (!url) { $("fUrl").focus(); return; } + const entry = { name: $("fName").value.trim(), url, icon: $("fIcon").value.trim() }; + if (editId === "new") links.push({ id: uid(), ...entry }); + else Object.assign(links.find((l) => l.id === editId), entry); + await save(); + closeForm(); +}); + +$("cancel").addEventListener("click", closeForm); +$("add").addEventListener("click", () => openForm()); +$("search").addEventListener("input", render); +$("editToggle").addEventListener("click", () => { + editing = !editing; + if (!editing) { editId = null; $("form").hidden = true; } + render(); +}); + +// Enter im Suchfeld öffnet den ersten Treffer +$("search").addEventListener("keydown", (e) => { + if (e.key === "Enter" && !editing) $("list").querySelector("a")?.click(); +}); + +$("export").addEventListener("click", () => { + const blob = new Blob([JSON.stringify(links, null, 2)], { type: "application/json" }); + const a = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: "lablinks.json" }); + a.click(); + URL.revokeObjectURL(a.href); +}); +$("import").addEventListener("click", () => $("file").click()); +$("file").addEventListener("change", async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const arr = JSON.parse(await file.text()); + links = arr + .filter((x) => x && x.name && x.url) + .map((x) => ({ id: uid(), name: String(x.name), url: normalizeUrl(String(x.url)) || String(x.url), icon: x.icon || "" })); + await save(); + render(); + } catch { alert("Ungültige JSON-Datei."); } + e.target.value = ""; +}); + +load(); +$("search").focus();