LabLinks Chrome-Erweiterung im Retro-Look
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
0955e3e86a
commit
fc9e0e6ecf
Binary file not shown.
|
After Width: | Height: | Size: 387 B |
Binary file not shown.
|
After Width: | Height: | Size: 110 B |
Binary file not shown.
|
After Width: | Height: | Size: 150 B |
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>LabLinks</title>
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="crt">
|
||||||
|
<header>
|
||||||
|
<h1>LAB<span>LINKS</span><i class="cursor"></i></h1>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="editToggle" title="Bearbeiten">[EDIT]</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<input id="search" type="text" placeholder="> suche…" autocomplete="off" spellcheck="false">
|
||||||
|
|
||||||
|
<ul id="list"></ul>
|
||||||
|
<p id="empty" hidden>// noch keine links.<br>// klicke [EDIT] → [+ NEU]</p>
|
||||||
|
|
||||||
|
<form id="form" hidden>
|
||||||
|
<div class="row">
|
||||||
|
<input id="fIcon" maxlength="2" placeholder="🖥" title="Emoji / Kürzel">
|
||||||
|
<input id="fName" placeholder="Name (z.B. Proxmox)" required>
|
||||||
|
</div>
|
||||||
|
<input id="fUrl" placeholder="https://192.168.1.10:8006" required>
|
||||||
|
<div class="row">
|
||||||
|
<button type="submit" class="primary">[SPEICHERN]</button>
|
||||||
|
<button type="button" id="cancel">[ABBRUCH]</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button id="add" hidden>[+ NEU]</button>
|
||||||
|
<button id="export" hidden>[EXPORT]</button>
|
||||||
|
<button id="import" hidden>[IMPORT]</button>
|
||||||
|
<input id="file" type="file" accept="application/json" hidden>
|
||||||
|
<span id="count"></span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 = '<span class="ico"></span><span class="txt"><span class="name"></span><span class="url"></span></span>';
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user