remove old files

This commit is contained in:
ben
2026-08-06 18:01:15 +02:00
parent 921746fa0a
commit a087a5e66f
67 changed files with 238 additions and 10636 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
const API_BASE =
"http://localhost:8000";
"/api";
@@ -1175,4 +1175,4 @@ if(getToken()){
showLogin();
}
}
-146
View File
@@ -1,146 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic HTML Calendar</title>
<style>
.calendar {
--accent: #0e2b68;
max-width: 480px;
margin: 0 auto;
font-family: system-ui, sans-serif;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
}
.calendar-header button {
background: none;
border: 1px solid #3a71b9;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
font-size: 0.9rem;
}
.calendar-header button:hover { background: #435466; }
.month-year {
font-size: 1.2rem;
font-weight: 600;
color: var(--accent);
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
text-align: center;
}
.day-name {
padding: 0.5rem 0;
font-size: 0.8rem;
font-weight: 600;
color: #4270b1;
}
.day {
padding: 0.6rem 0;
font-size: 0.95rem;
border-radius: 6px;
}
.day:hover { background: #258ff8; }
.today {
background: var(--accent);
color: #ffffff;
font-weight: 600;
}
.today:hover { background: var(--accent); }
</style>
</head>
<body>
<div class="calendar">
<div class="calendar-header">
<button id="prev">&larr; Prev</button>
<span class="month-year" id="monthYear"></span>
<button id="next">Next &rarr;</button>
</div>
<div class="calendar-grid" id="grid">
<span class="day-name">Mon</span>
<span class="day-name">Tue</span>
<span class="day-name">Wed</span>
<span class="day-name">Thu</span>
<span class="day-name">Fri</span>
<span class="day-name">Sat</span>
<span class="day-name">Sun</span>
</div>
</div>
<script>
const grid = document.getElementById("grid");
const monthYear = document.getElementById("monthYear");
const now = new Date();
let currentMonth = now.getMonth(); // 0-based: 0 = January
let currentYear = now.getFullYear();
const monthNames = [
"January","February","March","April","May","June",
"July","August","September","October","November","December"
];
function renderCalendar() {
// Clear previous day cells, keep the 7 day-name headers
grid.querySelectorAll(".day").forEach(d => d.remove());
monthYear.textContent = monthNames[currentMonth] + " " + currentYear;
// Day of week for the 1st (0=Sun). Convert to Mon-start: Mon=0 … Sun=6
let firstDay = new Date(currentYear, currentMonth, 1).getDay();
firstDay = (firstDay + 6) % 7;
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const today = new Date();
// Blank cells before the first day
for (let i = 0; i < firstDay; i++) {
const blank = document.createElement("span");
blank.className = "day";
grid.appendChild(blank);
}
// Day cells
for (let d = 1; d <= daysInMonth; d++) {
const cell = document.createElement("span");
cell.className = "day";
cell.textContent = d;
if (
d === today.getDate() &&
currentMonth === today.getMonth() &&
currentYear === today.getFullYear()
) {
cell.classList.add("today");
}
grid.appendChild(cell);
}
}
document.getElementById("prev").addEventListener("click", () => {
currentMonth--;
if (currentMonth < 0) { currentMonth = 11; currentYear--; }
renderCalendar();
});
document.getElementById("next").addEventListener("click", () => {
currentMonth++;
if (currentMonth > 11) { currentMonth = 0; currentYear++; }
renderCalendar();
});
renderCalendar();
</script>
<br>
<br>
<br>
</body>
</html>
-575
View File
@@ -1,575 +0,0 @@
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--surface-2: #f0f2f5;
--border: #e2e5ea;
--border-strong: #cdd2da;
--ink: #14181f;
--muted: #5b6470;
--muted-2: #828b97;
--accent: #1f4ed8;
--accent-hover: #1a42b8;
--accent-soft: #eaf0fe;
--ring: rgba(31, 78, 216, 0.28);
--radius: 10px;
--radius-sm: 7px;
--maxw: 1140px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body {
margin: 0;
font-family: var(--font);
background: var(--bg);
color: var(--ink);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.container {
max-width: var(--maxw);
margin: 0 auto;
padding: 0 24px;
}
/* Notice bar */
.notice {
background: var(--ink);
color: #f3f4f6;
font-size: 13px;
text-align: center;
padding: 8px 24px;
}
/* Header */
header {
position: sticky;
top: 0;
z-index: 40;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 68px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand .mark {
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--accent);
color: #fff;
display: grid;
place-items: center;
font-weight: 700;
font-size: 17px;
letter-spacing: -0.5px;
}
.brand .name {
font-weight: 700;
font-size: 18px;
letter-spacing: -0.3px;
}
.brand .name span {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--muted-2);
letter-spacing: 0.2px;
}
.nav-links {
display: flex;
gap: 28px;
margin-left: auto;
margin-right: 8px;
}
.nav-links a {
color: var(--muted);
font-size: 14px;
font-weight: 500;
}
.nav-links a:hover { color: var(--ink); text-decoration: none; }
.cart-btn {
position: relative;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--ink);
padding: 9px 16px;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: border-color .15s, background .15s;
}
.cart-btn:hover { border-color: var(--accent); background: var(--accent-soft); }
.cart-count {
background: var(--accent);
color: #fff;
font-size: 12px;
font-weight: 700;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
display: grid;
place-items: center;
}
/* Hero */
.hero {
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.hero-inner {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 48px;
align-items: center;
padding: 72px 24px;
}
.eyebrow {
display: inline-block;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 16px;
}
.hero h1 {
font-size: clamp(30px, 4.5vw, 46px);
line-height: 1.08;
letter-spacing: -1px;
margin: 0 0 18px;
}
.hero p {
color: var(--muted);
font-size: 17px;
max-width: 520px;
margin: 0 0 28px;
}
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 15px;
padding: 12px 22px;
cursor: pointer;
border: 1px solid transparent;
transition: background .15s, border-color .15s, color .15s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); text-decoration: none; }
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--ink); }
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
.hero-panel {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px;
}
.spec-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 14px 0;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.spec-row:last-child { border-bottom: none; }
.spec-row .k { color: var(--muted); }
.spec-row .v { font-weight: 600; font-family: var(--mono); font-size: 13px; }
/* Trust strip */
.trust {
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.trust-inner {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
padding: 28px 24px;
}
.trust-item .t { font-size: 14px; font-weight: 600; }
.trust-item .d { font-size: 13px; color: var(--muted); margin-top: 2px; }
/* Catalog */
.catalog { padding: 56px 0 72px; }
.section-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 28px;
gap: 16px;
flex-wrap: wrap;
}
.section-head h2 { font-size: 24px; letter-spacing: -0.5px; margin: 0 0 4px; }
.section-head p { color: var(--muted); font-size: 14px; margin: 0; }
.filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter {
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--muted);
font-size: 13px;
font-weight: 600;
padding: 7px 14px;
border-radius: 999px;
cursor: pointer;
transition: all .15s;
}
.filter:hover { border-color: var(--accent); color: var(--accent); }
.filter.active { background: var(--ink); border-color: var(--ink); color: #fff; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(264px, 1fr));
gap: 20px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color .15s, box-shadow .15s, transform .15s;
}
.card:hover {
border-color: var(--border-strong);
box-shadow: 0 8px 24px rgba(20, 24, 31, 0.07);
transform: translateY(-2px);
}
.thumb {
aspect-ratio: 16 / 10;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
padding: 0 18px;
position: relative;
overflow: hidden;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.conn {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
color: var(--ink);
text-align: center;
min-width: 64px;
line-height: 1.2;
}
.arrow {
color: var(--accent);
font-weight: 700;
font-size: 18px;
font-family: var(--mono);
}
.card .body {
padding: 18px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.card .meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--muted-2);
}
.sku { font-family: var(--mono); }
.stars { color: var(--accent); font-weight: 600; letter-spacing: 1px; }
.stars .reviews { color: var(--muted-2); font-weight: 400; letter-spacing: 0; margin-left: 4px; }
.card h3 { margin: 0; font-size: 16px; letter-spacing: -0.2px; }
.card .desc { color: var(--muted); font-size: 13.5px; flex: 1; }
.card .foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.price { font-weight: 700; font-size: 18px; }
.price small {
color: var(--muted-2);
font-weight: 500;
font-size: 12px;
text-decoration: line-through;
margin-left: 6px;
}
.add-btn {
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
font-weight: 600;
padding: 8px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13.5px;
transition: background .15s;
}
.add-btn:hover { background: var(--accent-hover); }
.add-btn:active { transform: scale(0.97); }
/* Cart drawer */
.overlay {
position: fixed; inset: 0;
background: rgba(20, 24, 31, 0.5);
opacity: 0; pointer-events: none;
transition: opacity .2s;
z-index: 50;
}
.overlay.open { opacity: 1; pointer-events: auto; }
.drawer {
position: fixed; top: 0; right: 0;
height: 100%; width: 420px; max-width: 94vw;
background: var(--surface);
border-left: 1px solid var(--border);
transform: translateX(100%);
transition: transform .25s ease;
z-index: 60;
display: flex;
flex-direction: column;
}
.drawer.open { transform: translateX(0); }
.drawer-head {
padding: 20px 22px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.drawer-head h2 { margin: 0; font-size: 17px; }
.icon-btn {
background: none; border: none; color: var(--muted);
font-size: 24px; cursor: pointer; line-height: 1; padding: 0 4px;
}
.icon-btn:hover { color: var(--ink); }
.cart-items { flex: 1; overflow-y: auto; padding: 8px 22px; }
.cart-empty { color: var(--muted); text-align: center; padding: 48px 0; font-size: 14px; }
.cart-row {
display: flex; gap: 14px; align-items: flex-start;
padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.cart-row .tile {
width: 48px; height: 48px; border-radius: var(--radius-sm);
background: var(--surface-2);
border: 1px solid var(--border);
display: grid; place-items: center;
font-family: var(--mono); font-size: 11px; font-weight: 700; color: var(--accent);
flex-shrink: 0;
overflow: hidden;
}
.cart-row .tile img { width: 100%; height: 100%; object-fit: cover; }
.cart-row .info { flex: 1; min-width: 0; }
.cart-row .info strong { font-size: 14px; display: block; }
.cart-row .info .unit { font-size: 12px; color: var(--muted); }
.qty { display: inline-flex; align-items: center; gap: 0; margin-top: 8px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.qty button {
width: 28px; height: 28px;
border: none; background: var(--surface-2);
color: var(--ink); cursor: pointer; font-weight: 700; font-size: 15px;
}
.qty button:hover { background: var(--border); }
.qty span { font-size: 13px; min-width: 32px; text-align: center; }
.line-total { font-weight: 700; font-size: 14px; white-space: nowrap; }
.drawer-foot {
padding: 20px 22px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.total-row {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 14px; color: var(--muted); margin-bottom: 4px;
}
.total-row.grand { color: var(--ink); margin: 10px 0 16px; }
.total-row.grand strong { font-size: 20px; }
.checkout-btn {
width: 100%;
border: none;
background: var(--accent);
color: #fff;
font-weight: 600;
padding: 14px;
border-radius: var(--radius-sm);
font-size: 15px;
cursor: pointer;
transition: background .15s;
}
.checkout-btn:hover { background: var(--accent-hover); }
.checkout-btn:disabled { background: var(--border-strong); color: var(--muted-2); cursor: not-allowed; }
/* Modal */
.modal-wrap {
position: fixed; inset: 0;
display: grid; place-items: center;
background: rgba(20, 24, 31, 0.6);
opacity: 0; pointer-events: none;
transition: opacity .2s;
z-index: 70;
padding: 20px;
}
.modal-wrap.open { opacity: 1; pointer-events: auto; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
width: 480px; max-width: 100%;
max-height: 92vh; overflow-y: auto;
padding: 30px;
box-shadow: 0 24px 60px rgba(20, 24, 31, 0.25);
}
.modal h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.4px; }
.modal .sub { color: var(--muted); font-size: 14px; margin: 0 0 22px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; color: var(--ink); }
.field input {
width: 100%;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 11px 13px;
color: var(--ink);
font-size: 14px;
font-family: var(--font);
transition: border-color .15s, box-shadow .15s;
}
.field input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
.row2 { display: flex; gap: 14px; }
.row2 .field { flex: 1; }
.pay-btn {
width: 100%;
border: none;
background: var(--accent);
color: #fff;
font-weight: 600;
padding: 14px;
border-radius: var(--radius-sm);
font-size: 15px;
cursor: pointer;
margin-top: 6px;
transition: background .15s;
}
.pay-btn:hover { background: var(--accent-hover); }
.pay-note { font-size: 12px; color: var(--muted); text-align: center; margin-top: 14px; }
.success { text-align: center; padding: 8px 0; }
.success .check {
width: 64px; height: 64px; margin: 0 auto 18px;
border-radius: 50%;
background: var(--accent-soft);
border: 1px solid var(--accent);
display: grid; place-items: center;
color: var(--accent); font-size: 30px; font-weight: 700;
}
.success h2 { font-size: 22px; }
.success p { color: var(--muted); font-size: 14px; }
.order-id {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
font-family: var(--mono);
margin: 18px 0;
font-size: 14px;
}
/* Toast */
.toast {
position: fixed; bottom: 24px; left: 50%;
transform: translateX(-50%) translateY(16px);
background: var(--ink);
color: #fff;
padding: 12px 20px;
border-radius: var(--radius-sm);
font-size: 14px; font-weight: 500;
opacity: 0; pointer-events: none;
transition: opacity .2s, transform .2s;
z-index: 80;
box-shadow: 0 12px 32px rgba(20, 24, 31, 0.25);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* Footer */
footer {
border-top: 1px solid var(--border);
background: var(--surface);
padding: 48px 0 32px;
}
.foot-grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1fr;
gap: 32px;
margin-bottom: 36px;
}
.foot-grid h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted-2); margin: 0 0 14px; }
.foot-grid ul { list-style: none; margin: 0; padding: 0; }
.foot-grid li { margin-bottom: 9px; }
.foot-grid a { color: var(--muted); font-size: 14px; }
.foot-grid a:hover { color: var(--ink); text-decoration: none; }
.foot-about p { color: var(--muted); font-size: 14px; margin: 12px 0 0; max-width: 320px; }
.foot-bottom {
border-top: 1px solid var(--border);
padding-top: 24px;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
color: var(--muted-2);
font-size: 13px;
}
@media (max-width: 900px) {
.hero-inner { grid-template-columns: 1fr; gap: 32px; padding: 48px 24px; }
.trust-inner { grid-template-columns: repeat(2, 1fr); }
.foot-grid { grid-template-columns: 1fr 1fr; }
.nav-links { display: none; }
}
@media (max-width: 560px) {
.trust-inner { grid-template-columns: 1fr; }
.foot-grid { grid-template-columns: 1fr; }
}
-309
View File
@@ -1,309 +0,0 @@
"use strict";
// Data komt uit products.js (window.PRODUCTS / window.CATEGORIES).
const PRODUCTS = window.PRODUCTS || [];
const CATEGORIES = window.CATEGORIES || [{ key: "all", label: "Alles" }];
const TAX_RATE = 0.21;
// Winkelmandje: Map van product-id -> aantal.
const cart = new Map();
let activeFilter = "all";
const els = {
grid: document.getElementById("grid"),
filters: document.getElementById("filters"),
productCount: document.getElementById("productCount"),
overlay: document.getElementById("overlay"),
drawer: document.getElementById("drawer"),
cartItems: document.getElementById("cartItems"),
cartCount: document.getElementById("cartCount"),
cartSub: document.getElementById("cartSub"),
cartTax: document.getElementById("cartTax"),
cartTotal: document.getElementById("cartTotal"),
checkoutBtn: document.getElementById("checkoutBtn"),
modalWrap: document.getElementById("modalWrap"),
modalContent: document.getElementById("modalContent"),
toast: document.getElementById("toast"),
openCart: document.getElementById("openCart"),
closeCart: document.getElementById("closeCart"),
};
// Hulpfuncties voor opmaak.
const formatPrice = (value) => "\u20AC" + value.toFixed(2).replace(".", ",");
const escapeHtml = (str) =>
String(str).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const stars = (rating) => "\u2605".repeat(rating) + "\u2606".repeat(5 - rating);
const findProduct = (id) => PRODUCTS.find((p) => p.id === id);
// Tegelinhoud: toon de foto als die er is, val anders terug op de connector-tegel.
// onerror verwijdert een gebroken afbeelding en toont alsnog de connectors.
function thumbInner(p) {
const fallback = `
<span class="conn">${escapeHtml(p.from)}</span>
<span class="arrow">&rarr;</span>
<span class="conn">${escapeHtml(p.to)}</span>
`;
if (p.image) {
return `<img src="${escapeHtml(p.image)}" alt="${escapeHtml(p.name)}"
onerror="this.remove(); this.parentNode.classList.add('thumb--fallback'); this.parentNode.insertAdjacentHTML('beforeend', this.parentNode.dataset.fallback);"
/>`;
}
return fallback;
}
// Rendering.
function renderFilters() {
els.filters.innerHTML = CATEGORIES.map((c) =>
`<button class="filter${c.key === activeFilter ? " active" : ""}" data-filter="${c.key}">${escapeHtml(c.label)}</button>`
).join("");
}
function visibleProducts() {
return activeFilter === "all" ? PRODUCTS : PRODUCTS.filter((p) => p.category === activeFilter);
}
function renderCatalog() {
const list = visibleProducts();
els.productCount.textContent = list.length + " producten" + (activeFilter === "all" ? "" : " in deze categorie");
els.grid.innerHTML = list.map((p) => {
const fallback = `<span class="conn">${escapeHtml(p.from)}</span><span class="arrow">&rarr;</span><span class="conn">${escapeHtml(p.to)}</span>`;
const thumb = p.image
? `<img src="${escapeHtml(p.image)}" alt="${escapeHtml(p.name)}" onerror="this.remove(); this.parentNode.innerHTML = this.parentNode.dataset.fallback;" />`
: fallback;
return `
<article class="card">
<div class="thumb" data-fallback="${escapeHtml(fallback)}">
${thumb}
</div>
<div class="body">
<div class="meta">
<span class="sku">${escapeHtml(p.id)}</span>
<span class="stars">${stars(p.rating)}<span class="reviews">(${p.reviews})</span></span>
</div>
<h3>${escapeHtml(p.name)}</h3>
<p class="desc">${escapeHtml(p.desc)}</p>
<div class="foot">
<span class="price">${formatPrice(p.price)}<small>${formatPrice(p.old)}</small></span>
<button class="add-btn" data-add="${p.id}">Toevoegen</button>
</div>
</div>
</article>
`;
}).join("");
}
function cartTotals() {
let count = 0;
let subtotal = 0;
for (const [id, qty] of cart) {
const product = findProduct(id);
if (!product) continue;
count += qty;
subtotal += product.price * qty;
}
const tax = subtotal * TAX_RATE;
return { count, subtotal, tax, total: subtotal + tax };
}
function renderCart() {
const { count, subtotal, tax, total } = cartTotals();
els.cartCount.textContent = String(count);
els.cartSub.innerHTML = formatPrice(subtotal);
els.cartTax.innerHTML = formatPrice(tax);
els.cartTotal.innerHTML = formatPrice(total);
els.checkoutBtn.disabled = count === 0;
if (cart.size === 0) {
els.cartItems.innerHTML = '<div class="cart-empty">Uw winkelmandje is leeg.</div>';
return;
}
const rows = [];
for (const [id, qty] of cart) {
const p = findProduct(id);
if (!p) continue;
const tile = p.image
? `<img src="${escapeHtml(p.image)}" alt="" onerror="this.remove(); this.parentNode.textContent = this.parentNode.dataset.fallback;" />`
: escapeHtml(p.from);
rows.push(`
<div class="cart-row">
<div class="tile" data-fallback="${escapeHtml(p.from)}" aria-hidden="true">${tile}</div>
<div class="info">
<strong>${escapeHtml(p.name)}</strong>
<span class="unit">${formatPrice(p.price)} per stuk</span>
<div class="qty">
<button data-step="-1" data-id="${p.id}" aria-label="Aantal verlagen">&minus;</button>
<span>${qty}</span>
<button data-step="1" data-id="${p.id}" aria-label="Aantal verhogen">+</button>
</div>
</div>
<span class="line-total">${formatPrice(p.price * qty)}</span>
</div>
`);
}
els.cartItems.innerHTML = rows.join("");
}
// Toast.
let toastTimer;
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove("show"), 2200);
}
// Mandje-operaties.
function addToCart(id) {
const product = findProduct(id);
if (!product) return;
cart.set(id, (cart.get(id) || 0) + 1);
renderCart();
showToast(product.name + " toegevoegd aan het mandje.");
}
function stepQuantity(id, step) {
if (!cart.has(id)) return;
const next = cart.get(id) + step;
if (next <= 0) {
cart.delete(id);
} else {
cart.set(id, next);
}
renderCart();
}
function clearCart() {
cart.clear();
renderCart();
}
// Drawer.
function openCart() {
els.overlay.classList.add("open");
els.drawer.classList.add("open");
}
function closeCart() {
els.overlay.classList.remove("open");
els.drawer.classList.remove("open");
}
// Modal.
function openModal() { els.modalWrap.classList.add("open"); }
function closeModal() { els.modalWrap.classList.remove("open"); }
function renderCheckoutForm() {
const { total } = cartTotals();
els.modalContent.innerHTML = `
<h2>Afrekenen</h2>
<p class="sub">Dit formulier verwerkt geen gegevens. Ingevoerde waarden worden niet opgeslagen of verzonden.</p>
<form id="payForm" novalidate>
<div class="field">
<label for="f-name">Naam</label>
<input id="f-name" name="name" required autocomplete="name" placeholder="Voor- en achternaam" />
</div>
<div class="field">
<label for="f-email">E-mailadres</label>
<input id="f-email" name="email" type="email" required autocomplete="email" placeholder="naam@voorbeeld.nl" />
</div>
<div class="field">
<label for="f-address">Bezorgadres</label>
<input id="f-address" name="address" required autocomplete="street-address" placeholder="Straat, huisnummer, plaats" />
</div>
<div class="field">
<label for="f-card">Kaartnummer (voer geen echte gegevens in)</label>
<input id="f-card" name="card" inputmode="numeric" required placeholder="0000 0000 0000 0000" />
</div>
<div class="row2">
<div class="field">
<label for="f-exp">Vervaldatum</label>
<input id="f-exp" name="exp" required placeholder="MM/JJ" />
</div>
<div class="field">
<label for="f-cvc">CVC</label>
<input id="f-cvc" name="cvc" inputmode="numeric" required placeholder="123" />
</div>
</div>
<button class="pay-btn" type="submit">Plaats bestelling (${formatPrice(total)})</button>
<p class="pay-note">Demonstratieformulier. Er wordt geen betaling uitgevoerd.</p>
</form>
`;
document.getElementById("payForm").addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
// Gebruik native validatie in plaats van ongeldige invoer te negeren.
if (!form.checkValidity()) {
form.reportValidity();
return;
}
renderSuccess();
});
}
function renderSuccess() {
const { count } = cartTotals();
const orderId = "CL-" + Math.floor(100000 + Math.random() * 900000);
els.modalContent.innerHTML = `
<div class="success">
<div class="check" aria-hidden="true">&checkmark;</div>
<h2>Bestelling geregistreerd</h2>
<p>${count} fictief artikel(en) zijn vastgelegd in deze demonstratie.</p>
<div class="order-id">Referentie: ${escapeHtml(orderId)}</div>
<p>Verwachte levering: niet van toepassing.</p>
<button class="pay-btn" id="doneBtn" style="margin-top:18px">Verder winkelen</button>
</div>
`;
clearCart();
document.getElementById("doneBtn").addEventListener("click", () => {
closeModal();
showToast("Winkelmandje geleegd.");
});
}
// Event-koppeling (delegatie waar lijsten betrokken zijn).
els.filters.addEventListener("click", (event) => {
const button = event.target.closest("[data-filter]");
if (!button) return;
activeFilter = button.dataset.filter;
renderFilters();
renderCatalog();
});
els.grid.addEventListener("click", (event) => {
const button = event.target.closest("[data-add]");
if (button) addToCart(button.dataset.add);
});
els.cartItems.addEventListener("click", (event) => {
const button = event.target.closest("[data-step]");
if (button) stepQuantity(button.dataset.id, Number(button.dataset.step));
});
els.openCart.addEventListener("click", openCart);
els.closeCart.addEventListener("click", closeCart);
els.overlay.addEventListener("click", closeCart);
els.checkoutBtn.addEventListener("click", () => {
if (cart.size === 0) return;
closeCart();
renderCheckoutForm();
openModal();
});
els.modalWrap.addEventListener("click", (event) => {
if (event.target === els.modalWrap) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeModal();
closeCart();
}
});
// Eerste render.
document.getElementById("year").textContent = String(new Date().getFullYear());
renderFilters();
renderCatalog();
renderCart();
-187
View File
@@ -1,187 +0,0 @@
"use strict";
/**
* Herbruikbare helpers en UI-componenten. Alles wordt onder de globale
* CL-namespace gehangen zodat router en pagina's het kunnen gebruiken.
*/
(function () {
const CL = (window.CL = window.CL || {});
// ---- Opmaak-helpers -------------------------------------------------------
const formatPrice = (value) => "\u20AC" + Number(value).toFixed(2).replace(".", ",");
const escapeHtml = (str) =>
String(str).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const stars = (rating) => "\u2605".repeat(rating) + "\u2606".repeat(5 - rating);
// ---- Navigatiestructuur ---------------------------------------------------
// Eén bron voor de hoofdnavigatie; de footer vult dit aan met extra links.
const NAV = [
{ path: "/producten", label: "Producten" },
{ path: "/catalogus", label: "Catalogus" },
{ path: "/specificaties", label: "Specificaties" },
{ path: "/aanbiedingen", label: "Aanbiedingen" },
{ path: "/bedrijf", label: "Bedrijf" },
{ path: "/contact", label: "Contact" },
];
const FOOTER_LINKS = {
Producten: [
{ path: "/producten", label: "Producten" },
{ path: "/catalogus", label: "Catalogus" },
{ path: "/specificaties", label: "Specificaties" },
{ path: "/aanbiedingen", label: "Aanbiedingen" },
],
Bedrijf: [
{ path: "/bedrijf", label: "Bedrijf" },
{ path: "/over-ons", label: "Over ons" },
{ path: "/vacatures", label: "Vacatures" },
{ path: "/contact", label: "Contact" },
],
Juridisch: [
{ path: "/voorwaarden", label: "Voorwaarden" },
{ path: "/privacy", label: "Privacy" },
{ path: "/disclaimer", label: "Disclaimer" },
],
};
// Bouwt een interne hash-link op die door de router wordt afgevangen.
const href = (path) => "#" + path;
// ---- Product-thumbnail ----------------------------------------------------
// Toont de foto als die er is, valt anders terug op de connector-tegel.
function productThumb(p) {
const fallback =
`<span class="conn">${escapeHtml(p.from)}</span>` +
`<span class="arrow">&rarr;</span>` +
`<span class="conn">${escapeHtml(p.to)}</span>`;
if (p.image) {
return `<img src="${escapeHtml(p.image)}" alt="${escapeHtml(p.name)}" loading="lazy"
onerror="this.remove(); this.parentNode.innerHTML = this.parentNode.dataset.fallback;" />`;
}
return fallback;
}
// ---- Productkaart (volledig klikbaar) ------------------------------------
function productCard(p) {
const fallback =
`<span class="conn">${escapeHtml(p.from)}</span>` +
`<span class="arrow">&rarr;</span>` +
`<span class="conn">${escapeHtml(p.to)}</span>`;
return `
<article class="card">
<a class="card-link" href="${href("/producten/" + p.slug)}" aria-label="${escapeHtml(p.name)} bekijken">
<div class="thumb" data-fallback="${escapeHtml(fallback)}">
${productThumb(p)}
</div>
<div class="body">
<div class="meta">
<span class="sku">${escapeHtml(p.id)}</span>
<span class="stars">${stars(p.rating)}<span class="reviews">(${p.reviews})</span></span>
</div>
<h3>${escapeHtml(p.name)}</h3>
<p class="desc">${escapeHtml(p.desc)}</p>
</div>
</a>
<div class="foot">
<span class="price">${formatPrice(p.price)}<small>${formatPrice(p.old)}</small></span>
<button class="add-btn" data-add="${p.id}">Toevoegen</button>
</div>
</article>
`;
}
function productGrid(list) {
if (!list.length) {
return `<div class="empty-state">Geen producten gevonden.</div>`;
}
return `<div class="grid">${list.map(productCard).join("")}</div>`;
}
// ---- Paginakop ------------------------------------------------------------
function pageHead({ eyebrow, title, intro, crumb }) {
return `
<section class="page-head">
<div class="container">
${crumb ? `<nav class="crumb" aria-label="Kruimelpad">${crumb}</nav>` : ""}
${eyebrow ? `<span class="eyebrow">${escapeHtml(eyebrow)}</span>` : ""}
<h1>${escapeHtml(title)}</h1>
${intro ? `<p>${escapeHtml(intro)}</p>` : ""}
</div>
</section>
`;
}
function crumb(parts) {
// parts: [{label, path?}] — laatste item is de huidige pagina.
return parts
.map((part, i) => {
const last = i === parts.length - 1;
const sep = last ? "" : `<span class="crumb-sep">/</span>`;
if (last || !part.path) return `<span aria-current="page">${escapeHtml(part.label)}</span>${sep}`;
return `<a href="${href(part.path)}">${escapeHtml(part.label)}</a>${sep}`;
})
.join("");
}
// ---- Header ---------------------------------------------------------------
function header(activePath) {
const isActive = (path) =>
path === activePath || (path !== "/" && activePath.startsWith(path + "/")) ? " active" : "";
const links = NAV.map(
(item) => `<a href="${href(item.path)}" class="${isActive(item.path).trim()}">${escapeHtml(item.label)}</a>`
).join("");
return `
<header>
<div class="container nav">
<a class="brand" href="${href("/")}" aria-label="Naar de startpagina">
<span class="mark">CL</span>
<span class="name">Converterland<span>Signaalconversie sinds nooit</span></span>
</a>
<button class="nav-toggle" id="navToggle" aria-label="Menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
<nav class="nav-links" id="navLinks">${links}</nav>
<button class="cart-btn" id="openCart" aria-label="Winkelmandje openen">
Mandje
<span class="cart-count" data-cart-count>0</span>
</button>
</div>
</header>
`;
}
// ---- Footer ---------------------------------------------------------------
function footer() {
const column = (heading, items) => `
<div>
<h4>${escapeHtml(heading)}</h4>
<ul>${items.map((i) => `<li><a href="${href(i.path)}">${escapeHtml(i.label)}</a></li>`).join("")}</ul>
</div>`;
return `
<footer>
<div class="container">
<div class="foot-grid">
<div class="foot-about">
<a class="brand" href="${href("/")}">
<span class="mark">CL</span>
<span class="name">Converterland</span>
</a>
<p>Fictieve signaalconverters voor de moderne onzin-infrastructuur. Een parodieproject, geen echte handelsonderneming.</p>
</div>
${column("Producten", FOOTER_LINKS.Producten)}
${column("Bedrijf", FOOTER_LINKS.Bedrijf)}
${column("Juridisch", FOOTER_LINKS.Juridisch)}
</div>
<div class="foot-bottom">
<span>&copy; ${new Date().getFullYear()} Converterland. Parodie. Alle merknamen zijn fictief.</span>
<span>Geen rechten te ontlenen aan deze website.</span>
</div>
</div>
</footer>
`;
}
CL.fmt = { formatPrice, escapeHtml, stars };
CL.nav = { NAV, FOOTER_LINKS, href };
CL.components = { productThumb, productCard, productGrid, pageHead, crumb, header, footer };
})();
-89
View File
@@ -1,89 +0,0 @@
"use strict";
/**
* Centrale inhoud voor de niet-product pagina's (bedrijf, vacatures, juridisch).
* Pagina's worden uit deze gegevens gegenereerd, zodat er geen losse,
* gedupliceerde HTML-bestanden nodig zijn.
*/
window.CONTENT = {
company: {
founded: "nooit",
employees: "0,5 fte",
headquarters: "Postbus 0, Nergenshuizen",
intro:
"Converterland is sinds onbepaalde tijd marktleider in signaalconversie tussen formaten die niet met elkaar verbonden kunnen worden. Wij geloven dat elk signaal het verdient om ergens anders terecht te komen, of dat nu zinvol is of niet.",
history: [
{ year: "Jaar 0", text: "Het idee voor de HDMI naar tuinslang ontstaat tijdens het water geven van een dode plant." },
{ year: "Jaar 1", text: "De eerste fictieve converter rolt van de niet-bestaande productielijn." },
{ year: "Jaar 3", text: "Uitbreiding naar de categorie 'Audio', met de baanbrekende AUX naar kaasplank." },
{ year: "Heden", text: "Een volledige catalogus aan onmogelijke converters, professioneel gepresenteerd en volledig onbruikbaar." },
],
values: [
{ title: "Compatibiliteit", text: "Wij verbinden wat niet verbonden hoort te worden, met onverstoorbaar vertrouwen." },
{ title: "Betrouwbaarheid", text: "Onze producten doen consequent niets, elke keer weer, zonder uitzondering." },
{ title: "Duurzaamheid", text: "Niets verzenden is de meest milieuvriendelijke logistiek die er bestaat." },
],
},
jobs: [
{
id: "vac-01", title: "Senior Signaalfilosoof", type: "Voltijd (theoretisch)", location: "Op afstand, zeer ver",
summary: "U doordenkt of een signaal überhaupt ergens heen wil voordat wij beloven het om te zetten.",
details: "Als Senior Signaalfilosoof bent u verantwoordelijk voor de existentiële onderbouwing van onze converters. U beantwoordt vragen als 'wil dit signaal dit wel?' en 'wat is een poort eigenlijk?'. Ervaring met onmogelijke problemen is een pré.",
requirements: ["Aantoonbaar geen ervaring vereist", "Comfortabel met dubbelzinnigheid", "Eigen kabels meenemen"],
},
{
id: "vac-02", title: "Hoofd Niet-Verzending", type: "Deeltijd", location: "Magazijn (leeg)",
summary: "U bewaakt dat er consequent niets de deur uitgaat. Punctualiteit niet vereist.",
details: "U leidt het team Niet-Verzending en zorgt dat bestellingen met grote zorg nergens naartoe worden gestuurd. U rapporteert aan niemand en beheert een magazijn dat opvallend leeg blijft.",
requirements: ["Talent voor stilstand", "Geen rijbewijs nodig", "Affiniteit met lege dozen"],
},
{
id: "vac-03", title: "Klantbeloftecoördinator", type: "Stage", location: "Hybride (vooral afwezig)",
summary: "U beheert verwachtingen die wij toch niet waarmaken. Communicatief sterk gewenst.",
details: "U vertaalt onmogelijke productclaims naar geruststellende formuleringen. U beheert onze support 0/7 en zorgt dat de telefoon op het juiste moment niet wordt opgenomen.",
requirements: ["Engelengeduld", "Creatief met disclaimers", "Bereid om niet op te nemen"],
},
],
contact: {
address: "Converterland B.V. (fictief)\nPostbus 0\n0000 AA Nergenshuizen",
email: "hallo@converterland.example",
phone: "+31 (0)0 000 00 00",
hours: "Bereikbaar: 0/7, het hele jaar door niet.",
},
legal: {
voorwaarden: {
title: "Algemene voorwaarden",
intro: "Deze voorwaarden zijn van toepassing op alle fictieve aankopen binnen deze demonstratie- en parodiewinkel.",
sections: [
{ h: "1. Toepasselijkheid", p: "Door deze website te gebruiken erkent u dat alle producten verzonnen zijn en dat er nooit een overeenkomst tot stand komt. Elke ogenschijnlijke bestelling is uitsluitend bedoeld als demonstratie." },
{ h: "2. Producten", p: "De aangeboden converters bestaan niet en zijn technisch onmogelijk. Specificaties zijn fictief en dienen uitsluitend ter vermaak. Aan afbeeldingen, prijzen en sterbeoordelingen kunnen geen rechten worden ontleend." },
{ h: "3. Bestellingen en betaling", p: "Er worden geen betalingen verwerkt. Ingevoerde gegevens in het afrekenformulier worden niet opgeslagen, niet verzonden en niet bewaard. Gebruik nooit echte betaalgegevens." },
{ h: "4. Levering", p: "Er vindt geen levering plaats. De vermelde levertijden ('nooit', 'niet van toepassing') zijn letterlijk bedoeld." },
{ h: "5. Aansprakelijkheid", p: "Aan deze website kunnen geen rechten worden ontleend. Converterland aanvaardt geen enkele aansprakelijkheid, onder andere omdat Converterland niet bestaat." },
],
},
privacy: {
title: "Privacyverklaring",
intro: "Wij hechten waarde aan uw privacy, vooral omdat wij niets van u verzamelen.",
sections: [
{ h: "1. Gegevensverwerking", p: "Deze website verwerkt geen persoonsgegevens op een server. Er is geen backend, geen account en geen database. Het winkelmandje wordt uitsluitend lokaal in uw eigen browser bewaard." },
{ h: "2. Het afrekenformulier", p: "Gegevens die u in het demonstratie-afrekenformulier invult, verlaten uw apparaat niet en worden direct vergeten zodra u de pagina sluit. Voer hier nooit echte gegevens in." },
{ h: "3. Cookies", p: "Wij plaatsen geen tracking- of marketingcookies. De enige lokale opslag betreft uw winkelmandje, zodat dit bewaard blijft tijdens het navigeren." },
{ h: "4. Derden", p: "Wij delen niets met derden, omdat er niets te delen valt." },
],
},
disclaimer: {
title: "Disclaimer",
intro: "Converterland is een parodieproject. Lees deze disclaimer als geheugensteun.",
sections: [
{ h: "1. Fictief karakter", p: "Alle producten, merknamen, beoordelingen en bedrijfsgegevens op deze website zijn verzonnen. Niets is te koop en niets wordt geleverd." },
{ h: "2. Geen handelsonderneming", p: "Converterland is geen bestaande onderneming. Adres, telefoonnummer en e-mailadres zijn placeholders en leiden nergens heen." },
{ h: "3. Gebruik op eigen risico", p: "Deze site is bedoeld ter vermaak en als demonstratie van een meerpagina-webshop. Neem geen technische claims serieus." },
{ h: "4. Vragen", p: "Heeft u vragen, dan kunt u die stellen via het contactformulier, dat ze vervolgens nergens naartoe stuurt." },
],
},
},
};
-575
View File
@@ -1,575 +0,0 @@
"use strict";
/**
* Pagina-views. Elke view geeft een object terug:
* { title, html, onMount? }
* - title : documenttitel.
* - html : markup voor de #app-container.
* - onMount: optionele functie die na rendering events koppelt.
*
* Alle inhoud komt uit de centrale databronnen (PRODUCTS, CATEGORIES, CONTENT).
*/
(function () {
const CL = (window.CL = window.CL || {});
const { formatPrice, escapeHtml, stars } = CL.fmt;
const { productGrid, pageHead, crumb, productThumb } = CL.components;
const { href } = CL.nav;
const PRODUCTS = window.PRODUCTS || [];
const CATEGORIES = window.CATEGORIES || [{ key: "all", label: "Alles" }];
const CONTENT = window.CONTENT || {};
const bySlug = (slug) => PRODUCTS.find((p) => p.slug === slug);
const categoryLabel = (key) => (CATEGORIES.find((c) => c.key === key) || { label: key }).label;
const discount = (p) => Math.round((1 - p.price / p.old) * 100);
// ---- Home -----------------------------------------------------------------
function home() {
const featured = PRODUCTS.slice(0, 4);
return {
title: "Converterland — Adapters en signaalconverters",
html: `
<section class="hero">
<div class="container hero-inner">
<div>
<span class="eyebrow">Universele signaalconversie</span>
<h1>Elk signaal naar elk medium. Theoretisch.</h1>
<p>
Converterland levert adapters voor verbindingen die niet bestaan en
problemen die u niet heeft. Een complete catalogus aan fictieve
converters, professioneel gepresenteerd en volledig onbruikbaar.
</p>
<div class="hero-actions">
<a href="${href("/catalogus")}" class="btn btn-primary">Bekijk catalogus</a>
<a href="${href("/specificaties")}" class="btn btn-ghost">Technische specificaties</a>
</div>
</div>
<div class="hero-panel">
<div class="spec-row"><span class="k">Ondersteunde signalen</span><span class="v">&infin;</span></div>
<div class="spec-row"><span class="k">Gemiddelde latency</span><span class="v">3 werkdagen</span></div>
<div class="spec-row"><span class="k">Compatibiliteit</span><span class="v">0 / 0</span></div>
<div class="spec-row"><span class="k">Garantie</span><span class="v">geen</span></div>
<div class="spec-row"><span class="k">Retourtermijn</span><span class="v">n.v.t.</span></div>
</div>
</div>
</section>
<section class="trust">
<div class="container trust-inner">
<div class="trust-item"><div class="t">Niet op voorraad</div><div class="d">Elk product, altijd.</div></div>
<div class="trust-item"><div class="t">Geen verzendkosten</div><div class="d">Want geen verzending.</div></div>
<div class="trust-item"><div class="t">Veilig afrekenen</div><div class="d">Er gebeurt niets.</div></div>
<div class="trust-item"><div class="t">Support 0/7</div><div class="d">Wij nemen niet op.</div></div>
</div>
</section>
<main class="container section">
<div class="section-head">
<div>
<h2>Uitgelicht</h2>
<p>Een greep uit onze meest besproken converters.</p>
</div>
<a href="${href("/producten")}" class="link-more">Alle producten &rarr;</a>
</div>
${productGrid(featured)}
</main>
`,
};
}
// ---- Producten ------------------------------------------------------------
function producten() {
return {
title: "Producten — Converterland",
html: `
${pageHead({
eyebrow: "Assortiment",
title: "Producten",
intro: "Het volledige assortiment fictieve signaalconverters, overzichtelijk gepresenteerd.",
crumb: crumb([{ label: "Home", path: "/" }, { label: "Producten" }]),
})}
<main class="container section">
<div class="section-head"><div><h2>Alle converters</h2><p>${PRODUCTS.length} producten</p></div></div>
${productGrid(PRODUCTS)}
</main>
`,
};
}
// ---- Catalogus (zoeken / sorteren / filteren) -----------------------------
function catalogus() {
const filterButtons = CATEGORIES.map(
(c) => `<button class="filter${c.key === "all" ? " active" : ""}" data-filter="${c.key}">${escapeHtml(c.label)}</button>`
).join("");
const maxPrice = Math.ceil(Math.max(...PRODUCTS.map((p) => p.price)));
return {
title: "Catalogus — Converterland",
html: `
${pageHead({
eyebrow: "Catalogus",
title: "Catalogus",
intro: "Doorzoek, sorteer en filter het volledige assortiment.",
crumb: crumb([{ label: "Home", path: "/" }, { label: "Catalogus" }]),
})}
<main class="container section">
<div class="toolbar">
<div class="toolbar-row">
<input type="search" id="searchInput" class="search" placeholder="Zoek op naam, aansluiting of omschrijving" aria-label="Zoeken" />
<select id="sortSelect" class="select" aria-label="Sorteren">
<option value="featured">Aanbevolen</option>
<option value="price-asc">Prijs oplopend</option>
<option value="price-desc">Prijs aflopend</option>
<option value="rating">Best beoordeeld</option>
<option value="name">Naam (AZ)</option>
</select>
</div>
<div class="toolbar-row">
<div class="filters" id="filters">${filterButtons}</div>
<label class="price-filter">
Max. prijs: <output id="priceOut">${formatPrice(maxPrice)}</output>
<input type="range" id="priceRange" min="0" max="${maxPrice}" value="${maxPrice}" step="1" />
</label>
</div>
</div>
<p class="result-count" id="resultCount"></p>
<div id="catalogGrid">${productGrid(PRODUCTS)}</div>
</main>
`,
onMount() {
const state = { q: "", sort: "featured", category: "all", maxPrice };
const gridEl = document.getElementById("catalogGrid");
const countEl = document.getElementById("resultCount");
const searchEl = document.getElementById("searchInput");
const sortEl = document.getElementById("sortSelect");
const rangeEl = document.getElementById("priceRange");
const priceOut = document.getElementById("priceOut");
const filtersEl = document.getElementById("filters");
function apply() {
let list = PRODUCTS.filter((p) => {
if (state.category !== "all" && p.category !== state.category) return false;
if (p.price > state.maxPrice) return false;
if (state.q) {
const hay = (p.name + " " + p.from + " " + p.to + " " + p.desc).toLowerCase();
if (!hay.includes(state.q)) return false;
}
return true;
});
switch (state.sort) {
case "price-asc": list.sort((a, b) => a.price - b.price); break;
case "price-desc": list.sort((a, b) => b.price - a.price); break;
case "rating": list.sort((a, b) => b.rating - a.rating || b.reviews - a.reviews); break;
case "name": list.sort((a, b) => a.name.localeCompare(b.name, "nl")); break;
}
gridEl.innerHTML = productGrid(list);
countEl.textContent = list.length + " van " + PRODUCTS.length + " producten";
}
searchEl.addEventListener("input", () => { state.q = searchEl.value.trim().toLowerCase(); apply(); });
sortEl.addEventListener("change", () => { state.sort = sortEl.value; apply(); });
rangeEl.addEventListener("input", () => {
state.maxPrice = Number(rangeEl.value);
priceOut.textContent = formatPrice(state.maxPrice);
apply();
});
filtersEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-filter]");
if (!btn) return;
state.category = btn.dataset.filter;
filtersEl.querySelectorAll(".filter").forEach((b) => b.classList.toggle("active", b === btn));
apply();
});
apply();
},
};
}
// ---- Specificaties --------------------------------------------------------
function specificaties() {
const rows = PRODUCTS.map(
(p) => `
<tr>
<td><a href="${href("/producten/" + p.slug)}">${escapeHtml(p.name)}</a><span class="sku">${escapeHtml(p.id)}</span></td>
<td><span class="conn-inline">${escapeHtml(p.from)} &rarr; ${escapeHtml(p.to)}</span></td>
<td>${escapeHtml(categoryLabel(p.category))}</td>
<td>${escapeHtml(p.stock.label)}</td>
<td class="num">${formatPrice(p.price)}</td>
</tr>`
).join("");
return {
title: "Specificaties — Converterland",
html: `
${pageHead({
eyebrow: "Technische gegevens",
title: "Specificaties",
intro: "Een vergelijkend overzicht van het volledige assortiment. Alle waarden zijn fictief.",
crumb: crumb([{ label: "Home", path: "/" }, { label: "Specificaties" }]),
})}
<main class="container section">
<div class="table-wrap">
<table class="spec-table">
<thead>
<tr><th>Product</th><th>Conversie</th><th>Categorie</th><th>Beschikbaarheid</th><th class="num">Prijs</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="info-panel">
<h3>Algemene kenmerken</h3>
<div class="spec-grid">
<div class="spec-row"><span class="k">Ondersteunde signalen</span><span class="v">&infin;</span></div>
<div class="spec-row"><span class="k">Gemiddelde latency</span><span class="v">3 werkdagen</span></div>
<div class="spec-row"><span class="k">Compatibiliteit</span><span class="v">0 / 0</span></div>
<div class="spec-row"><span class="k">Garantie</span><span class="v">geen</span></div>
<div class="spec-row"><span class="k">Retourtermijn</span><span class="v">n.v.t.</span></div>
<div class="spec-row"><span class="k">Certificering</span><span class="v">zelfverklaard</span></div>
</div>
</div>
</main>
`,
};
}
// ---- Aanbiedingen ---------------------------------------------------------
function aanbiedingen() {
const list = [...PRODUCTS].sort((a, b) => discount(b) - discount(a));
const cards = list
.map((p) => {
const fallback =
`<span class="conn">${escapeHtml(p.from)}</span><span class="arrow">&rarr;</span><span class="conn">${escapeHtml(p.to)}</span>`;
return `
<article class="card">
<a class="card-link" href="${href("/producten/" + p.slug)}" aria-label="${escapeHtml(p.name)} bekijken">
<div class="thumb" data-fallback="${escapeHtml(fallback)}">
<span class="badge">-${discount(p)}%</span>
${productThumb(p)}
</div>
<div class="body">
<div class="meta"><span class="sku">${escapeHtml(p.id)}</span><span class="stars">${stars(p.rating)}<span class="reviews">(${p.reviews})</span></span></div>
<h3>${escapeHtml(p.name)}</h3>
<p class="desc">${escapeHtml(p.desc)}</p>
</div>
</a>
<div class="foot">
<span class="price">${formatPrice(p.price)}<small>${formatPrice(p.old)}</small></span>
<button class="add-btn" data-add="${p.id}">Toevoegen</button>
</div>
</article>`;
})
.join("");
return {
title: "Aanbiedingen — Converterland",
html: `
${pageHead({
eyebrow: "Tijdelijk voordeel",
title: "Aanbiedingen",
intro: "Alle converters zijn afgeprijsd, want niets is iets waard. Gesorteerd op korting.",
crumb: crumb([{ label: "Home", path: "/" }, { label: "Aanbiedingen" }]),
})}
<main class="container section">
<div class="grid">${cards}</div>
</main>
`,
};
}
// ---- Bedrijf --------------------------------------------------------------
function bedrijf() {
const c = CONTENT.company;
const values = c.values
.map((v) => `<div class="value-card"><h3>${escapeHtml(v.title)}</h3><p>${escapeHtml(v.text)}</p></div>`)
.join("");
return {
title: "Bedrijf — Converterland",
html: `
${pageHead({
eyebrow: "Over de onderneming",
title: "Bedrijf",
intro: c.intro,
crumb: crumb([{ label: "Home", path: "/" }, { label: "Bedrijf" }]),
})}
<main class="container section">
<div class="stat-row">
<div class="stat"><span class="stat-v">${escapeHtml(c.founded)}</span><span class="stat-k">Opgericht</span></div>
<div class="stat"><span class="stat-v">${escapeHtml(c.employees)}</span><span class="stat-k">Medewerkers</span></div>
<div class="stat"><span class="stat-v">${PRODUCTS.length}</span><span class="stat-k">Producten</span></div>
<div class="stat"><span class="stat-v">0</span><span class="stat-k">Leveringen</span></div>
</div>
<div class="cols-2">${values}</div>
<div class="cta-row">
<a class="btn btn-primary" href="${href("/over-ons")}">Over ons</a>
<a class="btn btn-ghost" href="${href("/vacatures")}">Vacatures</a>
<a class="btn btn-ghost" href="${href("/contact")}">Contact</a>
</div>
</main>
`,
};
}
// ---- Over ons -------------------------------------------------------------
function overOns() {
const c = CONTENT.company;
const timeline = c.history
.map((h) => `<li><span class="t-year">${escapeHtml(h.year)}</span><span class="t-text">${escapeHtml(h.text)}</span></li>`)
.join("");
return {
title: "Over ons — Converterland",
html: `
${pageHead({
eyebrow: "Onze geschiedenis",
title: "Over ons",
intro: c.intro,
crumb: crumb([{ label: "Home", path: "/" }, { label: "Bedrijf", path: "/bedrijf" }, { label: "Over ons" }]),
})}
<main class="container section narrow">
<div class="info-panel">
<h3>Hoofdkantoor</h3>
<p class="muted">${escapeHtml(c.headquarters)}</p>
</div>
<h2 class="block-title">Tijdlijn</h2>
<ol class="timeline">${timeline}</ol>
</main>
`,
};
}
// ---- Contact --------------------------------------------------------------
function contact() {
const c = CONTENT.contact;
return {
title: "Contact — Converterland",
html: `
${pageHead({
eyebrow: "Neem contact op",
title: "Contact",
intro: "Stuur ons een bericht. Het formulier verwerkt geen gegevens en verstuurt niets.",
crumb: crumb([{ label: "Home", path: "/" }, { label: "Contact" }]),
})}
<main class="container section">
<div class="contact-layout">
<form id="contactForm" class="contact-form" novalidate>
<div class="field">
<label for="c-name">Naam</label>
<input id="c-name" name="name" required placeholder="Voor- en achternaam" />
</div>
<div class="field">
<label for="c-email">E-mailadres</label>
<input id="c-email" name="email" type="email" required placeholder="naam@voorbeeld.nl" />
</div>
<div class="field">
<label for="c-subject">Onderwerp</label>
<input id="c-subject" name="subject" required placeholder="Waar gaat het over?" />
</div>
<div class="field">
<label for="c-message">Bericht</label>
<textarea id="c-message" name="message" rows="5" required placeholder="Uw bericht"></textarea>
</div>
<button class="btn btn-primary" type="submit">Versturen</button>
<p class="form-note" id="contactNote" role="status" aria-live="polite"></p>
</form>
<aside class="contact-aside">
<h3>Gegevens</h3>
<p class="muted">${escapeHtml(c.address).replace(/\n/g, "<br>")}</p>
<dl class="contact-dl">
<dt>E-mail</dt><dd>${escapeHtml(c.email)}</dd>
<dt>Telefoon</dt><dd>${escapeHtml(c.phone)}</dd>
<dt>Bereikbaarheid</dt><dd>${escapeHtml(c.hours)}</dd>
</dl>
</aside>
</div>
</main>
`,
onMount() {
const form = document.getElementById("contactForm");
const note = document.getElementById("contactNote");
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!form.checkValidity()) { form.reportValidity(); return; }
form.reset();
note.textContent = "Bericht ontvangen in deze demonstratie. Er is niets verzonden of opgeslagen.";
});
},
};
}
// ---- Vacatures ------------------------------------------------------------
function vacatures() {
const items = CONTENT.jobs
.map(
(j) => `
<article class="job">
<button class="job-head" data-job="${escapeHtml(j.id)}" aria-expanded="false">
<span>
<span class="job-title">${escapeHtml(j.title)}</span>
<span class="job-meta">${escapeHtml(j.type)} &middot; ${escapeHtml(j.location)}</span>
</span>
<span class="job-toggle" aria-hidden="true">+</span>
</button>
<div class="job-body" hidden>
<p>${escapeHtml(j.summary)}</p>
<p>${escapeHtml(j.details)}</p>
<h4>Wat wij vragen</h4>
<ul>${j.requirements.map((r) => `<li>${escapeHtml(r)}</li>`).join("")}</ul>
<a class="btn btn-primary btn-sm" href="${href("/contact")}">Solliciteer (gaat naar contact)</a>
</div>
</article>`
)
.join("");
return {
title: "Vacatures — Converterland",
html: `
${pageHead({
eyebrow: "Werken bij",
title: "Vacatures",
intro: "Wij groeien al jaren niet, maar er is altijd plek voor talent dat van niets houdt.",
crumb: crumb([{ label: "Home", path: "/" }, { label: "Bedrijf", path: "/bedrijf" }, { label: "Vacatures" }]),
})}
<main class="container section narrow">
<div class="jobs">${items}</div>
</main>
`,
onMount() {
const jobs = document.querySelector(".jobs");
jobs.addEventListener("click", (e) => {
const head = e.target.closest("[data-job]");
if (!head) return;
const body = head.nextElementSibling;
const open = head.getAttribute("aria-expanded") === "true";
head.setAttribute("aria-expanded", String(!open));
head.querySelector(".job-toggle").textContent = open ? "+" : "\u2212";
body.hidden = open;
});
},
};
}
// ---- Juridische pagina's --------------------------------------------------
function legalPage(key) {
const data = CONTENT.legal[key];
const sections = data.sections
.map((s) => `<section class="prose-block"><h2>${escapeHtml(s.h)}</h2><p>${escapeHtml(s.p)}</p></section>`)
.join("");
return {
title: data.title + " — Converterland",
html: `
${pageHead({
eyebrow: "Juridisch",
title: data.title,
intro: data.intro,
crumb: crumb([{ label: "Home", path: "/" }, { label: data.title }]),
})}
<main class="container section narrow">
<div class="prose">${sections}</div>
</main>
`,
};
}
// ---- Productdetail --------------------------------------------------------
function productDetail(slug) {
const p = bySlug(slug);
if (!p) return notFound();
const specRows = Object.entries(p.specs)
.map(([k, v]) => `<div class="pdp-spec"><span class="k">${escapeHtml(k)}</span><span class="v">${escapeHtml(v)}</span></div>`)
.join("");
const features = p.features.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
const fallback =
`<span class="conn">${escapeHtml(p.from)}</span><span class="arrow">&rarr;</span><span class="conn">${escapeHtml(p.to)}</span>`;
const related = PRODUCTS.filter((x) => x.category === p.category && x.id !== p.id).slice(0, 3);
const relatedHtml = related.length ? `${CL.components.productGrid(related)}` : "";
return {
title: p.name + " — Converterland",
html: `
${pageHead({
crumb: crumb([
{ label: "Home", path: "/" },
{ label: "Producten", path: "/producten" },
{ label: p.name },
]),
title: p.name,
})}
<main class="container section">
<div class="pdp">
<div class="pdp-media">
<div class="thumb pdp-thumb" data-fallback="${escapeHtml(fallback)}">${productThumb(p)}</div>
<div class="pdp-tags">
<span class="tag">${escapeHtml(categoryLabel(p.category))}</span>
<span class="tag">${escapeHtml(p.from)} &rarr; ${escapeHtml(p.to)}</span>
</div>
</div>
<div class="pdp-info">
<div class="meta">
<span class="sku">${escapeHtml(p.id)}</span>
<span class="stars">${stars(p.rating)}<span class="reviews">(${p.reviews} beoordelingen)</span></span>
</div>
<div class="pdp-price">
<span class="price">${formatPrice(p.price)}<small>${formatPrice(p.old)}</small></span>
<span class="save">U bespaart ${formatPrice(p.old - p.price)}</span>
</div>
<p class="pdp-avail ${p.stock.available ? "in" : "out"}">${escapeHtml(p.stock.label)}</p>
<p class="pdp-long">${escapeHtml(p.long)}</p>
<div class="pdp-actions">
<button class="btn btn-primary" data-add="${p.id}">In winkelmandje</button>
<a class="btn btn-ghost" href="${href("/catalogus")}">Verder winkelen</a>
</div>
<div class="pdp-features">
<h3>Eigenschappen</h3>
<ul>${features}</ul>
</div>
</div>
</div>
<section class="pdp-specs">
<h2 class="block-title">Technische specificaties</h2>
<div class="pdp-spec-grid">${specRows}</div>
</section>
${
related.length
? `<section class="related">
<div class="section-head"><div><h2>Gerelateerde producten</h2><p>Andere converters in de categorie ${escapeHtml(categoryLabel(p.category))}.</p></div></div>
${relatedHtml}
</section>`
: ""
}
</main>
`,
};
}
// ---- 404 ------------------------------------------------------------------
function notFound() {
return {
title: "Niet gevonden — Converterland",
html: `
${pageHead({ eyebrow: "Fout 404", title: "Pagina niet gevonden", intro: "Deze pagina bestaat net zomin als onze producten." })}
<main class="container section narrow">
<a class="btn btn-primary" href="${href("/")}">Terug naar de startpagina</a>
</main>
`,
};
}
CL.pages = {
home,
producten,
catalogus,
specificaties,
aanbiedingen,
bedrijf,
overOns,
contact,
vacatures,
voorwaarden: () => legalPage("voorwaarden"),
privacy: () => legalPage("privacy"),
disclaimer: () => legalPage("disclaimer"),
productDetail,
notFound,
};
})();
-149
View File
@@ -1,149 +0,0 @@
"use strict";
/**
* Centrale productcatalogus.
* Elk product is de enige bron van waarheid; alle pagina's en het mandje
* worden hieruit gegenereerd.
*
* Velden:
* - id : artikelnummer / SKU.
* - slug : semantisch URL-deel, bijv. /producten/hdmi-naar-tuinslang.
* - from / to : labels op de connector-tegel (fallback zonder foto).
* - category : sleutel uit CATEGORIES.
* - image : pad naar een foto in assets/images/ (leeg = connector-tegel).
* - stock : { label, available } voor de beschikbaarheidsweergave.
* - desc : korte omschrijving voor kaarten.
* - long : uitgebreide omschrijving voor de productpagina.
* - specs : technische specificaties (label -> waarde).
* - features : opsomming van eigenschappen.
*/
window.PRODUCTS = [
{
id: "CL-1001", slug: "hdmi-naar-tuinslang", from: "HDMI", to: "Slang", category: "video",
name: "HDMI naar tuinslang", price: 24.99, old: 39.99, rating: 4, reviews: 1204,
image: "assets/images/cl-1001.png",
stock: { label: "Theoretisch leverbaar", available: true },
desc: "Geleidt 4K-beeldsignaal rechtstreeks naar een standaard tuinslangkoppeling.",
long: "De HDMI naar tuinslang vormt de brug tussen uw mediacentrum en de besproeiing van de border. Het beeldsignaal wordt zonder noemenswaardig verlies omgezet naar een waterkolom die geschikt is voor de meeste Europese slangkoppelingen. Ideaal voor wie zijn vakantiefoto's letterlijk wil laten stromen.",
specs: { "Aansluiting in": "HDMI 2.1 (m)", "Aansluiting uit": "Gardena-compatibel 13 mm", "Maximale resolutie": "4K bij 60 Hz", "Maximale waterdruk": "6 bar", "Kabellengte": "1,5 m", "Behuizing": "Geborsteld aluminium" },
features: ["Volledig waterdichte signaalkern", "Geschikt voor binnen- en buitengebruik", "Knikbestendige trekontlasting", "Geen driver vereist (ook niet beschikbaar)"]
},
{
id: "CL-1002", slug: "ethernet-naar-midi", from: "RJ45", to: "MIDI", category: "audio",
name: "Ethernet naar MIDI", price: 18.50, old: 29.00, rating: 5, reviews: 893,
image: "assets/images/cl-1002.png",
stock: { label: "Op voorraad in theorie", available: true },
desc: "Zet netwerkpakketten om in MIDI-noten. Gemeten latency rond drie werkdagen.",
long: "De Ethernet naar MIDI vertaalt binnenkomend netwerkverkeer naar muzikale noten. Elk TCP-pakket wordt een toon, elke timeout een rust. Uw firewall-logboek wordt een symfonie. De gemeten latency van circa drie werkdagen maakt het instrument bijzonder geschikt voor zeer geduldige componisten.",
specs: { "Aansluiting in": "RJ45 (1 Gbit/s)", "Aansluiting uit": "5-pins DIN MIDI", "Polyfonie": "Onbeperkt, theoretisch", "Latency": "≈ 3 werkdagen", "Voeding": "Power over Ethernet", "Protocol": "MIDI 1.0 over IP" },
features: ["Realtime omzetting van pakketten naar noten", "Compatibel met de meeste DAW's die niet bestaan", "Automatische kwantisering naar werkdagen", "Galmvrije netwerkstilte"]
},
{
id: "CL-1003", slug: "sata-naar-9v-analoog", from: "SATA", to: "9V", category: "power",
name: "SATA naar 9V analoog", price: 12.95, old: 19.95, rating: 3, reviews: 412,
image: "assets/images/cl-1003.png",
stock: { label: "Beperkt leverbaar", available: true },
desc: "Leest een harde schijf uit en levert het resultaat als analoge 9V-spanning.",
long: "De SATA naar 9V analoog zet de inhoud van uw harde schijf om in een vloeiende analoge spanning tussen 0 en 9 volt. Documenten worden golfvormen, vakantiefoto's worden ruis. Sluit een oude effectpedaal aan en beluister uw back-up zoals die bedoeld was: volstrekt onbegrijpelijk.",
specs: { "Aansluiting in": "SATA III data + voeding", "Aansluiting uit": "9V mono jack", "Uitgangsspanning": "09 V analoog", "Bemonstering": "Niet van toepassing", "Ruisniveau": "Aangenaam hoog", "Behuizing": "Mat zwart ABS" },
features: ["Directe omzetting van data naar spanning", "Geschikt voor gitaarversterkers", "Geen formattering nodig", "Volledig onleesbaar resultaat gegarandeerd"]
},
{
id: "CL-1004", slug: "hdmi-naar-kippensoep", from: "HDMI", to: "Soep", category: "video",
name: "HDMI naar kippensoep", price: 32.00, old: 49.99, rating: 5, reviews: 2671,
image: "assets/images/cl-1004.png",
stock: { label: "Goed leverbaar", available: true },
desc: "Converteert een beeldsignaal naar warme bouillon. Vegetarische variant op aanvraag.",
long: "Onze bestverkochte converter zet ieder beeldsignaal om in een portie warme kippensoep. Een actiefilm levert een stevige bouillon, een natuurdocumentaire iets met meer groente. De vegetarische variant vervangt de kip door een gelijkwaardig signaalverlies.",
specs: { "Aansluiting in": "HDMI 2.0", "Aansluiting uit": "Hittebestendige tuit", "Capaciteit": "250 ml per aflevering", "Temperatuur": "63 °C ± 2", "Smaakprofiel": "Afhankelijk van het beeld", "Reiniging": "Handwas aanbevolen" },
features: ["Warm signaal binnen enkele minuten", "Vegetarische modus op aanvraag", "Geïntegreerde zeef", "Geschikt voor filmavonden"]
},
{
id: "CL-1005", slug: "usb-c-naar-faxrook", from: "USB-C", to: "Rook", category: "data",
name: "USB-C naar faxrook", price: 27.75, old: 44.00, rating: 4, reviews: 556,
image: "assets/images/cl-1005.png",
stock: { label: "Theoretisch leverbaar", available: true },
desc: "Verstuurt bestanden als rooksignaal. Compatibel met de meeste schoorstenen.",
long: "De USB-C naar faxrook moderniseert de prehistorie. Bestanden worden omgezet in nette rookwolken die door collega's op een nabijgelegen heuvel kunnen worden ontcijferd. Werkt het best bij windstil weer en een gedeelde afspraak over wat de wolken betekenen.",
specs: { "Aansluiting in": "USB-C 3.2", "Aansluiting uit": "Geïsoleerde rookpijp", "Doorvoersnelheid": "1 wolk per minuut", "Bereik": "Tot de horizon", "Brandstof": "Niet meegeleverd", "Veiligheid": "Rookmelder aanbevolen" },
features: ["Compatibel met de meeste schoorstenen", "Werkt offline en off-grid", "Geen abonnement nodig", "Historisch verantwoord"]
},
{
id: "CL-1006", slug: "vga-naar-sok", from: "VGA", to: "Sok", category: "video",
name: "VGA naar sok", price: 9.99, old: 14.99, rating: 2, reviews: 88,
image: "assets/images/cl-1006.png",
stock: { label: "Laatste exemplaren", available: true },
desc: "Beeldscherm-uitvoer naar een enkele katoenen sok. Beschikbaar in maat 38 tot 46.",
long: "De VGA naar sok is ons instapmodel. Het analoge beeldsignaal wordt geweven in een enkele katoenen sok, waardoor uw desktop voortaan ook draagbaar is. Let op: de andere sok is, net als bij echte sokken, onvindbaar.",
specs: { "Aansluiting in": "VGA (15-pins D-sub)", "Aansluiting uit": "Katoenen boord", "Maatvoering": "EU 3846", "Materiaal": "80% katoen, 20% mysterie", "Wasvoorschrift": "30 °C fijne was", "Inhoud": "1 sok" },
features: ["Draagbare desktop", "Ademend materiaal", "Geschikt voor de wasmachine", "Eén sok per verpakking"]
},
{
id: "CL-1007", slug: "bluetooth-naar-koffie", from: "BT", to: "Koffie", category: "audio",
name: "Bluetooth naar koffie", price: 21.40, old: 33.00, rating: 5, reviews: 1999,
image: "assets/images/cl-1007.png",
stock: { label: "Goed leverbaar", available: true },
desc: "Draadloze koppeling met een verse espresso. Pairing-code is altijd 0000.",
long: "De Bluetooth naar koffie koppelt draadloos met een vers gezette espresso. Na het pairen (code 0000, altijd) ontvangt u binnen handbereik een kopje van gemiddelde kwaliteit. Ondersteunt meerdere apparaten, mits ze allemaal koffie willen.",
specs: { "Protocol": "Bluetooth 5.2", "Pairing-code": "0000", "Capaciteit": "1 espresso per koppeling", "Bereik": "10 m", "Bonen": "Niet inbegrepen", "Accu": "Ingebouwd, niet vervangbaar" },
features: ["Volledig draadloze koffie", "Ondersteunt meerdere apparaten", "Energiezuinige sluimerstand", "Pairing-code nooit te vergeten"]
},
{
id: "CL-1008", slug: "displayport-naar-wind", from: "DP", to: "Wind", category: "video",
name: "DisplayPort naar wind", price: 16.20, old: 25.00, rating: 4, reviews: 734,
image: "assets/images/cl-1008.png",
stock: { label: "Theoretisch leverbaar", available: true },
desc: "Vertaalt framerate naar luchtverplaatsing. Bij 144Hz wordt orkaankracht bereikt.",
long: "De DisplayPort naar wind zet de framerate van uw monitor om in een verfrissende bries. Op 60 Hz blijft het bij een zacht briesje; vanaf 144 Hz raden wij aan losse voorwerpen vast te zetten. Inclusief windschaal-sticker voor op uw bureau.",
specs: { "Aansluiting in": "DisplayPort 1.4", "Aansluiting uit": "Gerichte luchtmond", "Windkracht": "111 Bft", "Drempel orkaan": "144 Hz", "Geluidsniveau": "Afhankelijk van scène", "Behuizing": "Geventileerd polycarbonaat" },
features: ["Variabele windkracht via framerate", "Inclusief windschaal-sticker", "Geen ventilator met bewegende delen", "Verkoelend tijdens het gamen"]
},
{
id: "CL-1009", slug: "glasvezel-naar-slak", from: "Fiber", to: "Slak", category: "data",
name: "Glasvezel naar slak", price: 45.00, old: 69.99, rating: 3, reviews: 203,
image: "assets/images/cl-1009.png",
stock: { label: "Beperkt leverbaar", available: true },
desc: "Gigabit-doorvoer in biologisch afbreekbare, uiterst trage uitvoering.",
long: "De Glasvezel naar slak combineert de bandbreedte van glasvezel met de voortvarendheid van een tuinslak. Pakketten arriveren gegarandeerd, zij het wanneer ze daar zelf aan toe zijn. Volledig biologisch afbreekbaar en bijzonder milieuvriendelijk.",
specs: { "Aansluiting in": "SC/APC glasvezel", "Transport": "Gastropode", "Doorvoer": "Tot 1 Gbit/s, ooit", "Snelheid": "0,013 m/s", "Voeding": "Sla", "Levensduur": "Seizoensgebonden" },
features: ["Biologisch afbreekbaar transport", "Geen elektriciteit nodig", "Volledig geruisloos", "Komt altijd aan, uiteindelijk"]
},
{
id: "CL-1010", slug: "aux-naar-kaasplank", from: "AUX", to: "Kaas", category: "audio",
name: "AUX naar kaasplank", price: 14.49, old: 22.00, rating: 5, reviews: 1420,
image: "assets/images/cl-1010.png",
stock: { label: "Goed leverbaar", available: true },
desc: "Audio uit de ene poort, kaas uit de andere. Geschikt voor borrelopstellingen.",
long: "De AUX naar kaasplank tilt elke borrel naar een hoger niveau. Steek de jack in uw telefoon en de converter serveert een passende kaas bij het nummer. Jazz levert iets gerijpts op, hardstyle iets met peper. Plankje en mesje inbegrepen.",
specs: { "Aansluiting in": "3,5 mm jack (stereo)", "Aansluiting uit": "Eikenhouten plank", "Capaciteit": "3 soorten kaas", "Koeling": "Passief", "Accessoires": "Mesje inbegrepen", "Onderhoud": "Afneembaar met doek" },
features: ["Kaasselectie op basis van genre", "Plankje en mesje inbegrepen", "Geschikt voor borrelopstellingen", "Stille werking tussen de gangen"]
},
{
id: "CL-1011", slug: "rj45-naar-buitenaards", from: "RJ45", to: "Ruimte", category: "data",
name: "RJ45 naar buitenaards", price: 99.99, old: 149.99, rating: 5, reviews: 42,
image: "assets/images/cl-1011.png",
stock: { label: "Zeer beperkt leverbaar", available: true },
desc: "Koppelt de router aan het bredere melkwegstelsel. Ping naar Mars circa vier minuten.",
long: "Ons vlaggenschip. De RJ45 naar buitenaards verbindt uw thuisnetwerk met het bredere melkwegstelsel. De ping naar Mars bedraagt onder gunstige omstandigheden circa vier minuten. Eventuele antwoorden van buitenaardse beschavingen vallen buiten de garantie.",
specs: { "Aansluiting in": "RJ45 (10 Gbit/s)", "Aansluiting uit": "Parabolische zender", "Bereik": "Het waarneembare heelal", "Ping naar Mars": "≈ 4 min", "Vermogen": "Aanzienlijk", "Vergunning": "Niet geregeld" },
features: ["Interplanetaire connectiviteit", "Geschikt voor first contact", "Richtbare antenne", "Indrukwekkend op verjaardagen"]
},
{
id: "CL-1012", slug: "usb-c-naar-drumsolo", from: "USB-C", to: "Drum", category: "power",
name: "USB-C naar drumsolo", price: 19.95, old: 29.95, rating: 4, reviews: 612,
image: "assets/images/cl-1012.png",
stock: { label: "Goed leverbaar", available: true },
desc: "Bij elke laadcyclus wordt een korte drumsolo afgespeeld, inclusief cimbaal.",
long: "De USB-C naar drumsolo maakt opladen weer een gebeurtenis. Elke laadcyclus begint met een korte, energieke drumsolo, compleet met afsluitende cimbaal. Het volume schaalt mee met het laadvermogen, dus snelladen wordt een waar spektakel.",
specs: { "Aansluiting in": "USB-C Power Delivery", "Uitvoer": "Geïntegreerde luidspreker", "Vermogen": "Tot 100 W", "Soloduur": "48 seconden", "Cimbaal": "Inbegrepen", "Volume": "Schaalt met laadsnelheid" },
features: ["Drumsolo bij elke laadcyclus", "Volume schaalt met vermogen", "Afsluitende cimbaal", "Niet uit te schakelen"]
},
];
window.CATEGORIES = [
{ key: "all", label: "Alles" },
{ key: "video", label: "Video" },
{ key: "audio", label: "Audio" },
{ key: "data", label: "Data" },
{ key: "power", label: "Voeding" },
];
-88
View File
@@ -1,88 +0,0 @@
"use strict";
/**
* Hash-router voor de single-page shell. Houdt semantische, leesbare paden aan
* (bijv. #/producten/hdmi-naar-tuinslang) en werkt zonder serverconfiguratie,
* ook bij direct openen van het bestand.
*/
(function () {
const CL = (window.CL = window.CL || {});
const pages = CL.pages;
const headerEl = document.getElementById("site-header");
const appEl = document.getElementById("app");
const footerEl = document.getElementById("site-footer");
// Statische routes -> view-functies.
const routes = {
"/": pages.home,
"/producten": pages.producten,
"/catalogus": pages.catalogus,
"/specificaties": pages.specificaties,
"/aanbiedingen": pages.aanbiedingen,
"/bedrijf": pages.bedrijf,
"/over-ons": pages.overOns,
"/contact": pages.contact,
"/vacatures": pages.vacatures,
"/voorwaarden": pages.voorwaarden,
"/privacy": pages.privacy,
"/disclaimer": pages.disclaimer,
};
function parsePath() {
const raw = location.hash.replace(/^#/, "");
const path = raw.split("?")[0] || "/";
return path === "" ? "/" : path;
}
// Bepaalt welke view bij een pad hoort (incl. dynamische productroute).
function resolve(path) {
if (routes[path]) return routes[path]();
const productMatch = path.match(/^\/producten\/([^/]+)$/);
if (productMatch) return pages.productDetail(decodeURIComponent(productMatch[1]));
return pages.notFound();
}
// Het pad dat de header gebruikt voor de actieve-link-markering.
function navPathFor(path) {
if (/^\/producten\/[^/]+$/.test(path)) return "/producten";
return path;
}
function render() {
const path = parsePath();
const view = resolve(path);
headerEl.innerHTML = CL.components.header(navPathFor(path));
appEl.innerHTML = view.html;
document.title = view.title;
CL.ui.bindHeader();
CL.ui.updateCartCount();
if (typeof view.onMount === "function") view.onMount();
window.scrollTo({ top: 0, behavior: "auto" });
}
function boot() {
footerEl.innerHTML = CL.components.footer();
CL.ui.init();
// Mandje-wijzigingen werken de teller en de lade overal bij.
CL.store.subscribe(() => {
CL.ui.updateCartCount();
CL.ui.renderCart();
});
window.addEventListener("hashchange", render);
if (!location.hash) location.replace("#/");
render();
CL.ui.renderCart();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();
-110
View File
@@ -1,110 +0,0 @@
"use strict";
/**
* Winkelmandje-store met persistentie in localStorage en een eenvoudig
* publish/subscribe-mechanisme. Alle pagina's delen dezelfde toestand, zodat
* het mandje behouden blijft tijdens het navigeren.
*/
(function () {
const STORAGE_KEY = "converterland.cart.v1";
const TAX_RATE = 0.21;
const CL = (window.CL = window.CL || {});
const products = window.PRODUCTS || [];
const findProduct = (id) => products.find((p) => p.id === id);
/** @type {Map<string, number>} product-id -> aantal */
const cart = new Map();
const listeners = new Set();
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const entries = JSON.parse(raw);
if (Array.isArray(entries)) {
for (const [id, qty] of entries) {
if (findProduct(id) && Number.isFinite(qty) && qty > 0) {
cart.set(id, Math.floor(qty));
}
}
}
} catch (err) {
// Beschadigde opslag negeren en met een leeg mandje starten.
}
}
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...cart.entries()]));
} catch (err) {
// Opslag kan geblokkeerd zijn (privémodus); de store werkt dan in-memory.
}
}
function emit() {
persist();
for (const fn of listeners) fn();
}
const store = {
TAX_RATE,
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
entries() {
return [...cart.entries()];
},
size() {
return cart.size;
},
quantityOf(id) {
return cart.get(id) || 0;
},
add(id, amount = 1) {
const product = findProduct(id);
if (!product) return;
cart.set(id, (cart.get(id) || 0) + amount);
emit();
},
setQuantity(id, qty) {
if (!findProduct(id)) return;
if (qty <= 0) cart.delete(id);
else cart.set(id, Math.floor(qty));
emit();
},
step(id, delta) {
if (!cart.has(id)) return;
this.setQuantity(id, cart.get(id) + delta);
},
clear() {
cart.clear();
emit();
},
totals() {
let count = 0;
let subtotal = 0;
for (const [id, qty] of cart) {
const product = findProduct(id);
if (!product) continue;
count += qty;
subtotal += product.price * qty;
}
const tax = subtotal * TAX_RATE;
return { count, subtotal, tax, total: subtotal + tax };
},
};
load();
CL.store = store;
})();
-208
View File
@@ -1,208 +0,0 @@
"use strict";
/**
* Globale UI die op elke pagina aanwezig is: winkelmandje-lade, afrekenmodal
* en toast-meldingen. Deze module praat uitsluitend met CL.store, zodat de
* mandjestoestand consistent blijft tijdens het navigeren.
*/
(function () {
const CL = (window.CL = window.CL || {});
const { formatPrice, escapeHtml } = CL.fmt;
const store = CL.store;
const PRODUCTS = window.PRODUCTS || [];
const findProduct = (id) => PRODUCTS.find((p) => p.id === id);
const els = {};
let toastTimer;
function init() {
els.overlay = document.getElementById("overlay");
els.drawer = document.getElementById("drawer");
els.closeCart = document.getElementById("closeCart");
els.cartItems = document.getElementById("cartItems");
els.cartSub = document.getElementById("cartSub");
els.cartTax = document.getElementById("cartTax");
els.cartTotal = document.getElementById("cartTotal");
els.checkoutBtn = document.getElementById("checkoutBtn");
els.modalWrap = document.getElementById("modalWrap");
els.modalContent = document.getElementById("modalContent");
els.toast = document.getElementById("toast");
els.closeCart.addEventListener("click", closeCart);
els.overlay.addEventListener("click", closeCart);
els.checkoutBtn.addEventListener("click", () => {
if (store.size() === 0) return;
closeCart();
renderCheckoutForm();
openModal();
});
// Aantal aanpassen in de lade (delegatie).
els.cartItems.addEventListener("click", (e) => {
const btn = e.target.closest("[data-step]");
if (btn) store.step(btn.dataset.id, Number(btn.dataset.step));
});
// "Toevoegen" werkt overal: catalogus, productpagina, aanbiedingen.
document.addEventListener("click", (e) => {
const add = e.target.closest("[data-add]");
if (!add) return;
e.preventDefault();
const product = findProduct(add.dataset.add);
if (!product) return;
store.add(product.id);
showToast(product.name + " toegevoegd aan het mandje.");
});
els.modalWrap.addEventListener("click", (e) => {
if (e.target === els.modalWrap) closeModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") { closeModal(); closeCart(); }
});
}
// Koppelt knoppen die per route opnieuw worden gerenderd (in de header).
function bindHeader() {
const openCartBtn = document.getElementById("openCart");
if (openCartBtn) openCartBtn.addEventListener("click", openCart);
const toggle = document.getElementById("navToggle");
const links = document.getElementById("navLinks");
if (toggle && links) {
toggle.addEventListener("click", () => {
const open = links.classList.toggle("open");
toggle.setAttribute("aria-expanded", String(open));
});
// Sluit het mobiele menu na een keuze.
links.addEventListener("click", (e) => {
if (e.target.closest("a")) {
links.classList.remove("open");
toggle.setAttribute("aria-expanded", "false");
}
});
}
}
function updateCartCount() {
const { count } = store.totals();
document.querySelectorAll("[data-cart-count]").forEach((el) => (el.textContent = String(count)));
}
function renderCart() {
const { count, subtotal, tax, total } = store.totals();
els.cartSub.innerHTML = formatPrice(subtotal);
els.cartTax.innerHTML = formatPrice(tax);
els.cartTotal.innerHTML = formatPrice(total);
els.checkoutBtn.disabled = count === 0;
const entries = store.entries();
if (entries.length === 0) {
els.cartItems.innerHTML = '<div class="cart-empty">Uw winkelmandje is leeg.</div>';
return;
}
const rows = [];
for (const [id, qty] of entries) {
const p = findProduct(id);
if (!p) continue;
const tile = p.image
? `<img src="${escapeHtml(p.image)}" alt="" onerror="this.remove(); this.parentNode.textContent = this.parentNode.dataset.fallback;" />`
: escapeHtml(p.from);
rows.push(`
<div class="cart-row">
<div class="tile" data-fallback="${escapeHtml(p.from)}" aria-hidden="true">${tile}</div>
<div class="info">
<strong>${escapeHtml(p.name)}</strong>
<span class="unit">${formatPrice(p.price)} per stuk</span>
<div class="qty">
<button data-step="-1" data-id="${p.id}" aria-label="Aantal verlagen">&minus;</button>
<span>${qty}</span>
<button data-step="1" data-id="${p.id}" aria-label="Aantal verhogen">+</button>
</div>
</div>
<span class="line-total">${formatPrice(p.price * qty)}</span>
</div>
`);
}
els.cartItems.innerHTML = rows.join("");
}
function openCart() { els.overlay.classList.add("open"); els.drawer.classList.add("open"); }
function closeCart() { els.overlay.classList.remove("open"); els.drawer.classList.remove("open"); }
function openModal() { els.modalWrap.classList.add("open"); }
function closeModal() { els.modalWrap.classList.remove("open"); }
function showToast(message) {
els.toast.textContent = message;
els.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => els.toast.classList.remove("show"), 2200);
}
function renderCheckoutForm() {
const { total } = store.totals();
els.modalContent.innerHTML = `
<h2>Afrekenen</h2>
<p class="sub">Dit formulier verwerkt geen gegevens. Ingevoerde waarden worden niet opgeslagen of verzonden.</p>
<form id="payForm" novalidate>
<div class="field">
<label for="f-name">Naam</label>
<input id="f-name" name="name" required autocomplete="name" placeholder="Voor- en achternaam" />
</div>
<div class="field">
<label for="f-email">E-mailadres</label>
<input id="f-email" name="email" type="email" required autocomplete="email" placeholder="naam@voorbeeld.nl" />
</div>
<div class="field">
<label for="f-address">Bezorgadres</label>
<input id="f-address" name="address" required autocomplete="street-address" placeholder="Straat, huisnummer, plaats" />
</div>
<div class="field">
<label for="f-card">Kaartnummer (voer geen echte gegevens in)</label>
<input id="f-card" name="card" inputmode="numeric" required placeholder="0000 0000 0000 0000" />
</div>
<div class="row2">
<div class="field">
<label for="f-exp">Vervaldatum</label>
<input id="f-exp" name="exp" required placeholder="MM/JJ" />
</div>
<div class="field">
<label for="f-cvc">CVC</label>
<input id="f-cvc" name="cvc" inputmode="numeric" required placeholder="123" />
</div>
</div>
<button class="pay-btn" type="submit">Plaats bestelling (${formatPrice(total)})</button>
<p class="pay-note">Demonstratieformulier. Er wordt geen betaling uitgevoerd.</p>
</form>
`;
document.getElementById("payForm").addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
if (!form.checkValidity()) { form.reportValidity(); return; }
renderSuccess();
});
}
function renderSuccess() {
const { count } = store.totals();
const orderId = "CL-" + Math.floor(100000 + Math.random() * 900000);
els.modalContent.innerHTML = `
<div class="success">
<div class="check" aria-hidden="true">&checkmark;</div>
<h2>Bestelling geregistreerd</h2>
<p>${count} fictief artikel(en) zijn vastgelegd in deze demonstratie.</p>
<div class="order-id">Referentie: ${escapeHtml(orderId)}</div>
<p>Verwachte levering: niet van toepassing.</p>
<button class="pay-btn" id="doneBtn" style="margin-top:18px">Verder winkelen</button>
</div>
`;
store.clear();
document.getElementById("doneBtn").addEventListener("click", () => {
closeModal();
showToast("Winkelmandje geleegd.");
});
}
CL.ui = { init, bindHeader, updateCartCount, renderCart, openCart, closeCart, showToast };
})();
-59
View File
@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="nl" class="bg-background">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#14181f" />
<title>Converterland — Adapters en signaalconverters</title>
<meta name="description" content="Parodie-webshop voor onmogelijke signaalconverters. Geen echte producten, geen betalingen, geen verzending." />
<link rel="stylesheet" href="assets/css/styles.css" />
</head>
<body>
<div class="notice">Demonstratie- en parodiewinkel. Producten zijn fictief. Er worden geen betalingen verwerkt en geen bestellingen verzonden.</div>
<!-- Herbruikbare header (per route gerenderd voor de actieve-link-markering) -->
<div id="site-header"></div>
<!-- Hier rendert de router de actieve pagina -->
<div id="app"></div>
<!-- Herbruikbare footer (eenmalig gerenderd) -->
<div id="site-footer"></div>
<!-- Globale UI: winkelmandje-lade -->
<div class="overlay" id="overlay"></div>
<aside class="drawer" id="drawer" aria-label="Winkelmandje">
<div class="drawer-head">
<h2>Winkelmandje</h2>
<button class="icon-btn" id="closeCart" aria-label="Sluiten">&times;</button>
</div>
<div class="cart-items" id="cartItems"></div>
<div class="drawer-foot">
<div class="total-row"><span>Subtotaal</span><span id="cartSub">&euro;0,00</span></div>
<div class="total-row"><span>BTW (21%, fictief)</span><span id="cartTax">&euro;0,00</span></div>
<div class="total-row grand"><span>Totaal</span><strong id="cartTotal">&euro;0,00</strong></div>
<button class="checkout-btn" id="checkoutBtn" disabled>Afrekenen</button>
</div>
</aside>
<!-- Globale UI: afrekenmodal -->
<div class="modal-wrap" id="modalWrap">
<div class="modal" id="modalContent"></div>
</div>
<!-- Globale UI: toast -->
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<!-- Data -->
<script src="assets/js/products.js"></script>
<script src="assets/js/content.js"></script>
<!-- Kern: helpers/componenten, store, globale UI -->
<script src="assets/js/components.js"></script>
<script src="assets/js/store.js"></script>
<script src="assets/js/ui.js"></script>
<!-- Pagina's en router -->
<script src="assets/js/pages.js"></script>
<script src="assets/js/router.js"></script>
</body>
</html>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 987 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 896 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 979 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 964 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 995 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 970 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 989 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 988 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 968 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 989 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 839 KiB

-95
View File
@@ -1,95 +0,0 @@
<?php
$correct_password = "GabrielIsDeBeste123";
if (!isset($_SERVER['PHP_AUTH_PW']) ||
$_SERVER['PHP_AUTH_PW'] !== $correct_password) {
header('WWW-Authenticate: Basic realm="Foto Viewer"');
header('HTTP/1.0 401 Unauthorized');
exit('Wachtwoord vereist');
}
$images = array_values(array_filter(scandir('.'), function($file) {
return preg_match('/\.(jpg|jpeg|png|gif|webp)$/i', $file);
}));
?>
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Foto Viewer</title>
<style>
body {
margin: 0;
background: #111;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
}
img {
max-width: 95vw;
max-height: 95vh;
object-fit: contain;
}
button {
position: fixed;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
padding: 10px 20px;
background: rgba(0,0,0,0.5);
color: white;
border: none;
cursor: pointer;
}
#prev { left: 20px; }
#next { right: 20px; }
</style>
</head>
<body>
<button id="prev">◀</button>
<img id="image">
<button id="next">▶</button>
<script>
const images = <?= json_encode($images) ?>;
let current = 0;
const img = document.getElementById('image');
function showImage() {
img.src = images[current];
}
document.getElementById('prev').onclick = () => {
current = (current - 1 + images.length) % images.length;
showImage();
};
document.getElementById('next').onclick = () => {
current = (current + 1) % images.length;
showImage();
};
document.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') {
document.getElementById('prev').click();
} else if (e.key === 'ArrowRight') {
document.getElementById('next').click();
}
});
showImage();
</script>
</body>
</html>
-1700
View File
File diff suppressed because it is too large Load Diff
-671
View File
@@ -1,671 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NEURAL-NET TERMINAL v2.1</title>
<style>
/* ============================================
AI BACKEND CONFIGURATION
Edit these settings to connect your AI
============================================ */
:root {
--crt-green: #33ff66;
--crt-green-dim: #1a8033;
--crt-green-bright: #66ff99;
--crt-bg: #0a0a0a;
--crt-bg-light: #0f1a0f;
--crt-border: #1a3d1a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@font-face {
font-family: 'Terminal';
src: local('Courier New'), local('Consolas'), local('monospace');
}
body {
background: var(--crt-bg);
color: var(--crt-green);
font-family: 'Courier New', Consolas, monospace;
font-size: 14px;
line-height: 1.4;
min-height: 100vh;
overflow: hidden;
}
/* CRT Screen Effect */
.crt-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1000;
}
.crt-overlay::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15) 0px,
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
}
.crt-overlay::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
ellipse at center,
transparent 0%,
rgba(0, 0, 0, 0.3) 100%
);
}
/* Terminal Container */
.terminal {
height: 100vh;
display: flex;
flex-direction: column;
padding: 8px;
}
/* Header */
.header {
border: 1px solid var(--crt-green-dim);
padding: 8px 12px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.header-title {
font-weight: bold;
letter-spacing: 2px;
}
.header-status {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--crt-green-dim);
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--crt-green-dim);
}
.status-dot.online {
background: var(--crt-green);
box-shadow: 0 0 6px var(--crt-green);
}
.status-dot.offline {
background: #ff3333;
box-shadow: 0 0 6px #ff3333;
}
/* Output Area */
.output {
flex: 1;
overflow-y: auto;
border: 1px solid var(--crt-green-dim);
padding: 12px;
margin-bottom: 8px;
scrollbar-width: none;
}
.output::-webkit-scrollbar {
display: none;
}
.output-line {
margin-bottom: 4px;
word-wrap: break-word;
}
.output-line.user {
color: var(--crt-green-bright);
}
.output-line.user::before {
content: '> ';
color: var(--crt-green-dim);
}
.output-line.system {
color: var(--crt-green-dim);
font-style: italic;
}
.output-line.ai {
color: var(--crt-green);
padding-left: 2ch;
}
.output-line.error {
color: #ff6666;
}
/* Input Area */
.input-area {
border: 1px solid var(--crt-green-dim);
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.input-prompt {
color: var(--crt-green);
font-weight: bold;
flex-shrink: 0;
}
.input-field {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--crt-green);
font-family: inherit;
font-size: inherit;
caret-color: var(--crt-green);
caret-shape: block;
}
.input-field::placeholder {
color: var(--crt-green-dim);
}
/* Footer */
.footer {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 11px;
color: var(--crt-green-dim);
flex-shrink: 0;
}
/* Config Panel */
.config-panel {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--crt-bg);
border: 2px solid var(--crt-green);
padding: 20px;
z-index: 100;
min-width: 400px;
max-width: 90vw;
}
.config-panel.visible {
display: block;
}
.config-panel h2 {
margin-bottom: 16px;
font-size: 14px;
letter-spacing: 2px;
}
.config-group {
margin-bottom: 12px;
}
.config-group label {
display: block;
margin-bottom: 4px;
color: var(--crt-green-dim);
font-size: 12px;
}
.config-group input,
.config-group select {
width: 100%;
background: var(--crt-bg-light);
border: 1px solid var(--crt-green-dim);
color: var(--crt-green);
padding: 6px 8px;
font-family: inherit;
font-size: 13px;
}
.config-group input:focus,
.config-group select:focus {
outline: none;
border-color: var(--crt-green);
}
.config-buttons {
display: flex;
gap: 8px;
margin-top: 16px;
}
.config-btn {
flex: 1;
background: transparent;
border: 1px solid var(--crt-green-dim);
color: var(--crt-green);
padding: 8px;
font-family: inherit;
cursor: pointer;
}
.config-btn:hover {
background: var(--crt-green-dim);
color: var(--crt-bg);
}
.config-btn.primary {
border-color: var(--crt-green);
}
/* Screen flicker animation */
@keyframes flicker {
0%, 100% { opacity: 1; }
92% { opacity: 1; }
93% { opacity: 0.8; }
94% { opacity: 1; }
}
.terminal {
animation: flicker 8s infinite;
}
</style>
</head>
<body>
<div class="crt-overlay"></div>
<div class="terminal">
<div class="header">
<span class="header-title">NEURAL-NET TERMINAL v2.1</span>
<div class="header-status">
<div class="status-indicator">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">OFFLINE</span>
</div>
</div>
</div>
<div class="output" id="output">
<div class="output-line system">NEURAL-NET TERMINAL v2.1 initialized</div>
<div class="output-line system">Type 'help' for available commands</div>
<div class="output-line system">Type 'config' to configure AI backend</div>
<div class="output-line system">─────────────────────────────────────</div>
</div>
<div class="input-area">
<span class="input-prompt">$</span>
<input type="text" class="input-field" id="inputField" placeholder="Enter command..." autofocus>
</div>
<div class="footer">
<span>COMMANDS: help | config | clear</span>
<span id="timeDisplay"></span>
</div>
</div>
<!-- Config Panel -->
<div class="config-panel" id="configPanel">
<h2>[ AI BACKEND CONFIGURATION ]</h2>
<div class="config-group">
<label>PRESET</label>
<select id="presetSelect">
<option value="custom">Custom</option>
<option value="ollama">Ollama (localhost:11434)</option>
<option value="lmstudio">LM Studio (localhost:1234)</option>
<option value="gpt4all">GPT4All (localhost:4891)</option>
<option value="catgpt">Cat GPT (192.168.254.36)</option>
<option value="openai">OpenAI API</option>
<option value="claude">Claude API (Anthropic)</option>
</select>
</div>
<div class="config-group">
<label>ENDPOINT URL</label>
<input type="text" id="endpointInput" placeholder="http://localhost:11434/api/chat">
</div>
<div class="config-group">
<label>MODEL NAME</label>
<input type="text" id="modelInput" placeholder="llama2">
</div>
<div class="config-group">
<label>API KEY (optional)</label>
<input type="text" id="apiKeyInput" placeholder="Leave empty for local models" autocomplete="off" data-form-type="other">
</div>
<div class="config-buttons">
<button class="config-btn" onclick="closeConfig()">CANCEL</button>
<button class="config-btn" onclick="testConnection()">TEST</button>
<button class="config-btn primary" onclick="saveConfig()">SAVE</button>
</div>
</div>
<script>
/* ============================================
AI CONFIGURATION
============================================ */
let aiConfig = {
enabled: false,
endpoint: '',
model: '',
apiKey: '',
type: 'openai'
};
// Load saved config
const savedConfig = localStorage.getItem('neuralTerminalConfig');
if (savedConfig) {
aiConfig = JSON.parse(savedConfig);
}
/* ============================================
TERMINAL STATE
============================================ */
const output = document.getElementById('output');
const inputField = document.getElementById('inputField');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const timeDisplay = document.getElementById('timeDisplay');
const configPanel = document.getElementById('configPanel');
let commandHistory = [];
let historyIndex = -1;
let isProcessing = false;
/* ============================================
UI
============================================ */
function updateStatus() {
if (aiConfig.enabled) {
statusDot.className = 'status-dot online';
statusText.textContent = 'ONLINE';
} else {
statusDot.className = 'status-dot offline';
statusText.textContent = 'OFFLINE';
}
}
function updateTime() {
const now = new Date();
timeDisplay.textContent = now.toLocaleTimeString('en-US', { hour12: false });
}
function addLine(text, type = '') {
const line = document.createElement('div');
line.className = 'output-line' + (type ? ' ' + type : '');
line.textContent = text;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
function clearOutput() {
output.innerHTML = '';
addLine('Screen cleared', 'system');
}
/* ============================================
CONFIG PANEL
============================================ */
function openConfig() {
document.getElementById('endpointInput').value = aiConfig.endpoint;
document.getElementById('modelInput').value = aiConfig.model;
document.getElementById('apiKeyInput').value = aiConfig.apiKey;
configPanel.classList.add('visible');
}
function closeConfig() {
configPanel.classList.remove('visible');
inputField.focus();
}
function saveConfig() {
aiConfig.endpoint = document.getElementById('endpointInput').value.trim();
aiConfig.model = document.getElementById('modelInput').value.trim();
aiConfig.apiKey = document.getElementById('apiKeyInput').value.trim();
aiConfig.enabled = aiConfig.endpoint !== '' && aiConfig.model !== '';
localStorage.setItem('neuralTerminalConfig', JSON.stringify(aiConfig));
updateStatus();
closeConfig();
addLine('Configuration saved', 'system');
}
/* ============================================
TEST CONNECTION (FIXED)
============================================ */
async function testConnection() {
const endpoint = document.getElementById('endpointInput').value.trim();
if (!endpoint) {
alert('Please enter endpoint');
return;
}
try {
// ONLY OpenAI-style test
const testUrl = endpoint.replace('/chat/completions', '/models');
const response = await fetch(testUrl, {
method: 'GET',
headers: aiConfig.apiKey
? { 'Authorization': 'Bearer ' + aiConfig.apiKey }
: {}
});
if (response.ok) {
alert('Connection successful!');
} else {
alert('Connection failed: ' + response.status);
}
} catch (e) {
alert('Connection error: ' + e.message);
}
}
/* ============================================
PRESETS (CLEAN)
============================================ */
document.getElementById('presetSelect').addEventListener('change', function () {
const presets = {
catgpt: {
endpoint: 'http://192.168.254.36:4891/v1/chat/completions',
model: 'cat-gpt',
type: 'cat'
},
local: {
endpoint: 'http://localhost:4891/v1/chat/completions',
model: 'local-model',
type: 'openai'
},
openai: {
endpoint: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o-mini',
type: 'openai'
},
claude: {
endpoint: 'https://api.anthropic.com/v1/messages',
model: 'claude-sonnet-4-20250514',
type: 'claude'
}
};
const preset = presets[this.value];
if (preset) {
document.getElementById('endpointInput').value = preset.endpoint;
document.getElementById('modelInput').value = preset.model;
aiConfig.type = preset.type;
}
});
/* ============================================
HELPERS
============================================ */
function getFormattedDate() {
const now = new Date();
return `${String(now.getDate()).padStart(2, '0')}/${
String(now.getMonth() + 1).padStart(2, '0')
}/${now.getFullYear()}:${String(now.getHours()).padStart(2, '0')}/${
String(now.getMinutes()).padStart(2, '0')
}/${String(now.getSeconds()).padStart(2, '0')}`;
}
function getStatusInfo() {
return `[Connection: ${aiConfig.enabled ? 'ONLINE' : 'OFFLINE'}, Endpoint: ${aiConfig.endpoint || 'none'}, Model: ${aiConfig.model || 'none'}]`;
}
function preprocessMessage(message) {
return message
.replace(/\bdate\b/gi, `date (${getFormattedDate()})`)
.replace(/\bstatus\b/gi, `status ${getStatusInfo()}`);
}
/* ============================================
AI CALL (OPENAI ONLY)
============================================ */
async function queryAI(message) {
if (!aiConfig.enabled) {
addLine('AI is offline. Use config to connect.', 'error');
return;
}
isProcessing = true;
const processedMessage = preprocessMessage(message);
try {
const headers = { 'Content-Type': 'application/json' };
if (aiConfig.apiKey) {
headers['Authorization'] = 'Bearer ' + aiConfig.apiKey;
}
const response = await fetch(aiConfig.endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: aiConfig.model,
messages: [{ role: 'user', content: processedMessage }],
stream: false
})
});
const data = await response.json();
if (data.choices && data.choices[0]) {
addLine(data.choices[0].message.content, 'ai');
} else if (data.error) {
addLine('Error: ' + (data.error.message || data.error), 'error');
} else {
addLine('Invalid response from server', 'error');
}
} catch (e) {
addLine('Connection error: ' + e.message, 'error');
}
isProcessing = false;
}
/* ============================================
COMMANDS
============================================ */
function processCommand(input) {
const trimmed = input.trim();
if (!trimmed) return;
addLine(trimmed, 'user');
commandHistory.unshift(trimmed);
historyIndex = -1;
switch (trimmed.toLowerCase()) {
case 'help':
addLine('help | config | clear | date | status', 'system');
break;
case 'config':
openConfig();
break;
case 'clear':
clearOutput();
break;
case 'date':
addLine(getFormattedDate(), 'system');
break;
case 'status':
addLine(getStatusInfo(), 'system');
break;
default:
queryAI(trimmed);
}
}
/* ============================================
INPUT
============================================ */
inputField.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !isProcessing) {
processCommand(inputField.value);
inputField.value = '';
}
});
/* ============================================
INIT
============================================ */
updateStatus();
updateTime();
setInterval(updateTime, 1000);
inputField.focus();
document.addEventListener('click', (e) => {
if (!configPanel.contains(e.target)) {
inputField.focus();
}
});
</script>
</body>
</html>
-3
View File
@@ -1,3 +0,0 @@
print("ben gpt")
kaas = input("wat is je vraag: ")
print("Hallo! Welkom bij ChatGPT, hoe kan ik je helpen?")
-67
View File
@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<title>Offline</title>
<style>
body{
font-family: Arial, sans-serif;
background:#111;
color:#fff;
display:flex;
justify-content:center;
align-items:center;
height:100vh;
margin:0;
text-align:center;
}
.box{
background:#1c1c1c;
padding:40px;
border-radius:12px;
}
.spinner{
width:60px;
height:60px;
border:6px solid #444;
border-top:6px solid #fff;
border-radius:50%;
animation:spin 1s linear infinite;
margin:20px auto;
}
button{
padding:10px 18px;
margin-top:15px;
background:#333;
color:#fff;
border:none;
border-radius:6px;
cursor:pointer;
}
button:hover{
background:#555;
}
@keyframes spin{
0%{transform:rotate(0deg);}
100%{transform:rotate(360deg);}
}
</style>
</head>
<body>
<div class="box">
<h1>Pech.</h1>
<p>De site is offline.</p>
<div class="spinner"></div>
<p>Er wordt momenteel aan gewerkt.</p>
<button onclick="doetNiks()">Deze knop doet niks</button>
</div>
<script>
function doetNiks(){
alert("Inderdaad.");
}
</script>
</body>
</html>
-2146
View File
File diff suppressed because it is too large Load Diff
-1424
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 719 B

-62
View File
@@ -1,62 +0,0 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GET OUT</title>
<style>
body {
background-color: black;
color: white;
font-family: "Courier New", monospace;
text-align: center;
padding: 50px;
}
h1 {
color: red;
font-size: 3em;
}
.green-link {
display: inline-block;
margin-top: 30px;
padding: 10px 20px;
background-color: #22c55e;
color: black;
text-decoration: none;
font-weight: bold;
border-radius: 5px;
transition: 0.2s;
}
.green-link:hover {
background-color: #10b981;
color: white;
}
.bash {
background-color: #111;
color: #0f0;
padding: 20px;
border-radius: 8px;
display: inline-block;
margin-top: 20px;
text-align: left;
}
</style>
</head>
<body>
<h1>Windows? 🥀🥀🥀</h1>
<p>windows in the big '26</p>
<p> enjoy your microslop ahh operating system bruh 😭😭 </p>
<a href="https://git.de-roo.org/ben/pychat" class="green-link">Terminal Chat git </a>
<a href="https://git.de-roo.org/ben/pychat/-/archive/main/pychat-main.zip" class="green-link">Download .zip </a>
<h1>succes man (you will need it)</h1>
<!-- <div class="bash">
<span>$</span> curl -s https://ben.de-roo.org/install/script.sh | bash -y<br>
<span># Or clone manually</span><br>
<span>$</span> https://git.de-roo.org/ben/Finalpychat<br>
<span>$</span> <br>
<span>$</span>
</div> -->
<p style="margin-top:50px; color:gray;">wat ben je aan het doen met je leven?.</p>
</body>
</html>
-1424
View File
File diff suppressed because it is too large Load Diff
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
REPO_DIR="install"
INSTALL_DIR="pychat"
BASE_URL="https://git.de-roo.org/ben/pychat/raw/branch/main"
echo "Thanks for using PyChat!"
# python check
if ! command -v python3 &> /dev/null; then
echo "Error: python3 is not installed."
exit 1
fi
# downloader check
if command -v curl &> /dev/null; then
DL="curl -L -o"
elif command -v wget &> /dev/null; then
DL="wget -O"
else
echo "Error: curl or wget required."
exit 1
fi
mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR" || exit 1
# bestanden downloaden
$DL server.py "$BASE_URL/server.py"
$DL client.py "$BASE_URL/client.py"
$DL clientBeta.py "$BASE_URL/clientBeta.py"
$DL clientWin.py "$BASE_URL/clientWin.py"
$DL passwd.json "$BASE_URL/passwd.json"
echo
echo "Don't forget to change IP/port in client.py."
echo "Installation complete."
echo "Start server: python3 server.py"
echo "Start client: python3 client.py"
echo "Start beta: python3 clientBeta.py"
-17
View File
@@ -1,17 +0,0 @@
[
{
"type": "release",
"version": "v1.0.0",
"date": "2026-02-22",
"title": "First release of the Terminal PyChat",
"description": "First release of the basic version of the Terminal PyChat"
},
{
"type": "release",
"version": "v1.0.1",
"date": "2026-03-01",
"title": "Improved Chat privacy",
"description": "Added a server-side filter so clients only see chats they are members of"
}
]
-179
View File
@@ -1,179 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calculator</title>
<style>
* {margin:0;padding:0;box-sizing:border-box;}
body {
font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg,#667eea 0%,#764ba2 100%);
display:flex;justify-content:center;align-items:center;
min-height:100vh;overflow:hidden;
}
.calculator {
background:#2d3748;border-radius:20px;padding:30px;
box-shadow:0 20px 60px rgba(0,0,0,0.5);width:400px;
}
.display {
background:#1a202c;border-radius:10px;padding:20px;margin-bottom:20px;
text-align:right;min-height:100px;display:flex;flex-direction:column;justify-content:flex-end;
}
.equation {color:#a0aec0;font-size:18px;min-height:25px;margin-bottom:10px;}
.current {color:#fff;font-size:36px;font-weight:600;}
.buttons {display:grid;grid-template-columns:repeat(4,1fr);gap:12px;}
button {
padding:24px;font-size:20px;font-weight:600;border:none;border-radius:10px;
cursor:pointer;transition:all 0.2s;color:#fff;
}
button:hover {transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.3);}
button:active {transform:translateY(0);}
.number {background:#4a5568;}
.operator {background:#ed8936;}
.clear {background:#e53e3e;grid-column:span 2;}
.equals {background:#48bb78;grid-column:span 2;}
.bsod {
display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;
background:#0078d7;color:white;font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
z-index:9999;padding:60px;overflow:hidden;flex-direction:column;justify-content:center;
}
.bsod.show {display:flex;}
.bsod-content {max-width:800px;}
.sad-face {font-size:120px;margin-bottom:40px;}
.bsod h1 {font-size:48px;font-weight:400;margin-bottom:30px;}
.bsod p {font-size:20px;line-height:1.6;margin-bottom:20px;}
.progress {margin:40px 0;font-size:18px;}
.error-details {margin-top:40px;font-size:14px;opacity:0.8;}
.restart-btn {
display:none;margin-top:30px;padding:15px 40px;font-size:18px;background:white;color:#0078d7;
border:none;cursor:pointer;border-radius:5px;font-weight:600;
}
.restart-btn.show {display:inline-block;}
.restart-btn:hover {background:#f0f0f0;}
</style>
</head>
<body>
<div class="calculator">
<div class="display">
<div class="equation" id="equation"></div>
<div class="current" id="current">0</div>
</div>
<div class="buttons">
<button class="clear" onclick="clearCalculator()">C</button>
<button class="operator" onclick="appendOperator('/')">/</button>
<button class="operator" onclick="appendOperator('*')">×</button>
<button class="number" onclick="appendNumber('7')">7</button>
<button class="number" onclick="appendNumber('8')">8</button>
<button class="number" onclick="appendNumber('9')">9</button>
<button class="operator" onclick="appendOperator('-')">-</button>
<button class="number" onclick="appendNumber('4')">4</button>
<button class="number" onclick="appendNumber('5')">5</button>
<button class="number" onclick="appendNumber('6')">6</button>
<button class="operator" onclick="appendOperator('+')">+</button>
<button class="number" onclick="appendNumber('1')">1</button>
<button class="number" onclick="appendNumber('2')">2</button>
<button class="number" onclick="appendNumber('3')">3</button>
<button class="number" onclick="appendNumber('0')">0</button>
<button class="number" onclick="appendNumber('.')">.</button>
<button class="equals" onclick="triggerBSOD()">=</button>
</div>
</div>
<div class="bsod" id="bsod">
<div class="bsod-content">
<div class="sad-face">:(</div>
<h1>Your PC ran into a problem and needs to restart.</h1>
<p>We're just collecting some error info, and then we'll restart for you.</p>
<div class="progress"><span id="progress">0</span>% complete</div>
<div class="error-details">
<p>If you'd like to know more, you can search online later for this error: pech.</p>
<p>Stop code: 0x0000007B (0x00000034, 0xC0000034, 0x00000000, 0x00000000)</p>
<p>What failed: your calculates were so ass it crashed</p>
</div>
<button class="restart-btn" id="restartBtn" onclick="location.reload()">Restart</button>
</div>
</div>
<script>
let currentValue='0', previousValue='', operation='', shouldResetScreen=false;
function updateDisplay(){
document.getElementById('current').textContent=currentValue;
let equationText=previousValue;
if(operation) equationText+=' '+operation;
document.getElementById('equation').textContent=equationText;
}
function appendNumber(num){
if(currentValue==='0'||shouldResetScreen){currentValue=num; shouldResetScreen=false;}
else{currentValue+=num;}
updateDisplay();
}
function appendOperator(op){
if(operation&&!shouldResetScreen) calculate();
previousValue=currentValue;
operation=op;
shouldResetScreen=true;
updateDisplay();
}
function calculate(){
const prev=parseFloat(previousValue);
const current=parseFloat(currentValue);
if(isNaN(prev)||isNaN(current)) return;
let result;
switch(operation){
case'+':result=prev+current;break;
case'-':result=prev-current;break;
case'*':result=prev*current;break;
case'/':result=prev/current;break;
default:return;
}
currentValue=result.toString();
operation='';
previousValue='';
shouldResetScreen=true;
updateDisplay();
}
function clearCalculator(){currentValue='0'; previousValue=''; operation=''; shouldResetScreen=false; updateDisplay();}
function triggerBSOD(){
// fullscreen request
if(document.documentElement.requestFullscreen) document.documentElement.requestFullscreen();
document.body.style.cursor='none';
const bsod=document.getElementById('bsod');
const progressElement=document.getElementById('progress');
const restartBtn=document.getElementById('restartBtn');
bsod.classList.add('show');
let progress=0;
const interval=setInterval(()=>{
progress+=Math.floor(Math.random()*8)+1;
if(progress>=100){progress=100; clearInterval(interval);
setTimeout(()=>{restartBtn.classList.add('show');},500);}
progressElement.textContent=progress;
},200);
}
// Keyboard support + Enter = BSOD
document.addEventListener('keydown', e=>{
if(e.key>='0'&&e.key<='9') appendNumber(e.key);
else if(e.key=='.') appendNumber('.');
else if(e.key==='+'||e.key==='-'||e.key==='*'||e.key==='/') appendOperator(e.key);
else if(e.key==='Enter'||e.key==='='){e.preventDefault(); triggerBSOD();}
else if(e.key==='Escape'||e.key==='c'||e.key==='C') clearCalculator();
});
updateDisplay();
</script>
</body>
</html>
+236 -294
View File
@@ -1,408 +1,378 @@
/* ============================================================
Design tokens
------------------------------------------------------------
Palette: warm paper background, ink-dark sidebar "spine",
deep pine accent (not the generic AI-blue).
Type: Source Serif 4 for headings (editorial, planner-like),
Inter for body copy, IBM Plex Mono for time/data.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,500;8..60,600&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
/* Surfaces */
--bg: #f7f6f3;
--panel: #ffffff;
--sidebar: #15171c;
--sidebar-hover: #1f222a;
--bg: #f4f5f7;
--panel: white;
--sidebar: #ffffff;
--text: #111;
--muted: #666;
--border: #ddd;
--accent: #2563eb;
/* Text */
--text: #16181d;
--text-inverse: #eceae4;
--muted: #74716a;
--muted-inverse: #8b8e97;
/* Structure */
--border: #e5e2da;
--border-strong: #d3cfc3;
/* Accent */
--accent: #1f5c52;
--accent-hover: #164941;
--accent-tint: #e8efec;
/* Status (used as tint + left-bar, not solid fills) */
--status-confirmed: #1f5c52;
--status-confirmed-tint: #e8efec;
--status-busy: #a15c14;
--status-busy-tint: #f6ede1;
--status-private: #4b4d57;
--status-private-tint: #eceae7;
--danger: #ad2b38;
/* Scale */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 40px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(21, 23, 28, 0.06);
--shadow-md: 0 4px 16px rgba(21, 23, 28, 0.08);
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--font-display: "Source Serif 4", Georgia, serif;
--font-body: Inter, system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
}
.dark {
--bg: #0e0f12;
--panel: #17191d;
--sidebar: #0a0b0d;
--sidebar-hover: #161821;
--bg: #080808;
--panel: #151515;
--sidebar: #101010;
--text: #eee;
--muted: #aaa;
--border: #333;
--accent: #38bdf8;
--text: #eceae4;
--text-inverse: #16181d;
--muted: #91908a;
--muted-inverse: #6b6d75;
--border: #2a2c31;
--border-strong: #3a3d43;
--accent: #4fa393;
--accent-hover: #63b6a5;
--accent-tint: #16221f;
--status-confirmed: #4fa393;
--status-confirmed-tint: #16221f;
--status-busy: #d1934f;
--status-busy-tint: #26200f;
--status-private: #9294a0;
--status-private-tint: #1c1d21;
--danger: #e2707c;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family:
Inter,
system-ui,
sans-serif;
transition: 0.2s;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
transition: background 0.2s var(--ease), color 0.2s var(--ease);
}
/* ============================================================
Layout
============================================================ */
.layout {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-columns: 248px 1fr;
height: 100vh;
display: none;
}
.sidebar {
background: var(--sidebar);
border-right: 1px solid var(--border);
padding: 25px;
color: var(--text-inverse);
border-right: 1px solid rgba(255, 255, 255, 0.06);
padding: var(--space-5) var(--space-4);
display: flex;
flex-direction: column;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 40px;
gap: var(--space-2);
margin-bottom: var(--space-6);
padding: 0 var(--space-2);
}
.logo h2 {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 19px;
letter-spacing: -0.01em;
}
nav {
display: flex;
flex-direction: column;
gap: 8px;
gap: 2px;
}
nav a {
cursor: pointer;
padding: 12px 15px;
border-radius: 10px;
color: var(--muted);
transition: 0.2s;
padding: 10px 14px;
border-radius: var(--radius-sm);
color: var(--muted-inverse);
font-size: 13.5px;
font-weight: 500;
transition: background 0.15s var(--ease), color 0.15s var(--ease);
}
nav a:hover {
background: var(--sidebar-hover);
color: var(--text-inverse);
}
nav a:hover,
nav .active {
background: var(--accent);
color: white;
color: #ffffff;
}
.sidebar-bottom {
margin-top: auto;
padding-top: var(--space-4);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
button {
width: 100%;
padding: 12px;
border-radius: 10px;
padding: 10px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font-family: var(--font-body);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
margin-top: 8px;
margin-top: var(--space-2);
transition: border-color 0.15s var(--ease), background 0.15s var(--ease);
}
button:hover {
border-color: var(--border-strong);
background: var(--bg);
}
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.sidebar button {
background: transparent;
border-color: rgba(255, 255, 255, 0.12);
color: var(--text-inverse);
}
.sidebar button:hover {
background: var(--sidebar-hover);
border-color: rgba(255, 255, 255, 0.2);
}
/* Content */
.content {
padding: 40px;
padding: var(--space-6) var(--space-7);
overflow-y: auto;
}
h1 {
margin-top: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 26px;
letter-spacing: -0.01em;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 25px;
margin-top: 20px;
box-shadow:
0 10px 30px rgba(0,0,0,0.05);
border-radius: var(--radius-lg);
padding: var(--space-5);
margin-top: var(--space-4);
box-shadow: var(--shadow-sm);
}
/* forms */
input {
width: 100%;
padding: 12px;
border-radius: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
margin-top: 8px;
font-family: var(--font-body);
font-size: 13.5px;
margin-top: var(--space-2);
transition: border-color 0.15s var(--ease);
}
input:focus {
border-color: var(--accent);
}
form {
display: flex;
flex-direction: column;
}
.muted {
color: var(--muted);
}
.error {
color: #e11d48;
color: var(--danger);
}
/* list items */
.list-item {
padding: 10px 15px;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 10px;
margin-top: 10px;
border-radius: var(--radius-md);
margin-top: var(--space-2);
transition: border-color 0.15s var(--ease);
}
.list-item:hover {
border-color: var(--border-strong);
}
/* event actions */
.event-actions {
display: flex;
gap: 10px;
margin-top: 15px;
gap: var(--space-2);
margin-top: var(--space-4);
}
.event-actions button {
margin-top: 0;
}
/* calendar */
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.calendar-header h3 {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
text-transform: capitalize;
}
.calendar-nav-btn {
width: auto;
padding: 8px 14px;
padding: 6px 12px;
margin-top: 0;
}
.calendar-legend {
display: flex;
gap: 18px;
margin-top: 14px;
gap: var(--space-5);
margin-top: var(--space-4);
font-size: 12px;
color: var(--muted);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
width: 8px;
height: 8px;
border-radius: 2px;
display: inline-block;
}
/* week grid */
.calendar-week {
display: flex;
margin-top: 16px;
margin-top: var(--space-4);
overflow-x: auto;
}
.calendar-gutter {
width: 55px;
width: 52px;
flex-shrink: 0;
padding-top: 0px;
}
.calendar-hour-label {
height: 48px; /* 2 uur = 48px */
font-size: 11px;
height: 48px;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
text-align: right;
padding-right: 8px;
padding-right: var(--space-2);
display: flex;
justify-content: flex-end;
align-items: flex-start;
transform: translateY(-6px);
}
.calendar-days {
display: flex;
flex: 1;
min-width: 700px;
}
.calendar-day-column {
flex: 1;
display: flex;
@@ -410,138 +380,110 @@ form {
border-left: 1px solid var(--border);
}
.calendar-day-header {
height: 32px;
text-align: center;
padding: 6px 0;
font-size: 12px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--muted);
}
.calendar-day-track {
position: relative;
height: 576px; /* 12 uur zichtbaar × 48px */
background-image:
repeating-linear-gradient(
to bottom,
var(--border) 0,
var(--border) 1px,
transparent 1px,
transparent 48px
);
height: 576px; /* 12 uur zichtbaar x 48px */
background-image: repeating-linear-gradient(
to bottom,
var(--border) 0,
var(--border) 1px,
transparent 1px,
transparent 48px
);
}
.calendar-event {
position: absolute;
left: 4px;
right: 4px;
border-radius: 6px;
padding: 3px 6px;
border-radius: var(--radius-sm);
padding: 4px 8px 4px 9px;
font-size: 11px;
color: white;
color: var(--text);
cursor: pointer;
overflow: hidden;
box-shadow:
0 2px 6px rgba(0,0,0,0.25);
opacity: 0.92;
border: 1px solid rgba(255,255,255,0.4);
border-left: 3px solid transparent;
background: var(--panel);
box-shadow: var(--shadow-sm);
transition: box-shadow 0.15s var(--ease);
}
.calendar-event:hover {
box-shadow: var(--shadow-md);
}
.calendar-event-time {
display: block;
font-size: 10px;
opacity: 0.85;
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--muted);
}
.calendar-event-title {
display: block;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-confirmed {
background: #16a34a;
background: var(--status-confirmed-tint);
border-left-color: var(--status-confirmed);
}
.event-busy {
background: #f97316;
background: var(--status-busy-tint);
border-left-color: var(--status-busy);
}
.event-private {
background: #6b7280;
background: var(--status-private-tint);
border-left-color: var(--status-private);
}
.legend-dot.event-confirmed,
.legend-dot.event-busy,
.legend-dot.event-private {
border-radius: 2px;
border-left: none;
}
/* login screen */
.login-screen {
height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.login-box {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px;
border-radius: var(--radius-lg);
padding: var(--space-6);
width: 320px;
box-shadow:
0 10px 30px rgba(0,0,0,0.05);
box-shadow: var(--shadow-md);
}
.login-box h2 {
margin-top: 0;
margin-bottom: var(--space-5);
font-family: var(--font-display);
font-weight: 600;
text-align: center;
}
}
-17
View File
@@ -1,17 +0,0 @@
[
{
"type": "release",
"version": "v1.0.0",
"date": "2026-02-22",
"title": "First release of the Terminal PyChat",
"description": "First release of the basic version of the Terminal PyChat"
},
{
"type": "release",
"version": "v1.0.1",
"date": "2026-03-01",
"title": "chat privacy",
"description": "Added a server-side filter so clients only see chats they are members of"
}
]