Files
oikos/docs/install.html
Ulas Kalayci 9883abda79 feat: rebrand accent color to Violet #6c3aed
Replace Amber brand color with Violet (#6c3aed) across the entire app.

- tokens.css: accent palette → Violet (light #6c3aed, dark #a78bfa)
- Logo, favicon, PWA icons, Apple touch icon regenerated
- GitHub Pages (index.html, install.html): accent + inline SVG updated
- generate-icons.js: gradient updated for future icon generation
- Semantic colors (warning, notes, meals) intentionally unchanged
2026-05-06 16:06:43 +02:00

1237 lines
74 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Install Oikos — Self-Hosted Family Planner</title>
<meta name="description" content="Step-by-step installation guide for Oikos. Get your self-hosted family planner running in minutes with Docker.">
<link rel="canonical" href="https://ulsklyc.github.io/oikos/install.html">
<link rel="icon" type="image/svg+xml" href="logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&family=DM+Serif+Display&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ff-display: 'DM Serif Display', Georgia, serif;
--ff-body: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
--bg: #FAFAFB;
--bg-alt: #F2F1F6;
--surface: #FFFFFF;
--border: #E5E4EA;
--text-1: #181620;
--text-2: #5E5C6B;
--text-3: #8F8D9A;
--accent: #6c3aed;
--accent-hover: #5b2fd4;
--accent-soft: #ede9fe;
--accent-glow: rgba(108, 58, 237, 0.12);
--success: #15803D;
--success-bg: #F0FDF4;
--warning: #B45309;
--warning-bg: #FFFBEB;
--code-bg: #1C1B22;
--code-text: #E2E1EC;
--shadow-card: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.08);
--radius: 16px;
}
[data-theme="dark"] {
--bg: #1A1A18;
--bg-alt: #141413;
--surface: #222220;
--border: #2A2A28;
--text-1: #F5F4F1;
--text-2: #AEADB0;
--text-3: #8E8D89;
--accent: #a78bfa;
--accent-hover: #9066f5;
--accent-soft: #1e1040;
--accent-glow: rgba(167, 139, 250, 0.15);
--code-bg: #141413;
--code-text: #E2E1DC;
--shadow-card: 0 1px 3px rgba(0,0,0,0.25);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #1A1A18; --bg-alt: #141413; --surface: #222220; --border: #2A2A28;
--text-1: #F5F4F1; --text-2: #AEADB0; --text-3: #8E8D89;
--accent: #a78bfa; --accent-hover: #9066f5; --accent-soft: #1e1040;
--accent-glow: rgba(167, 139, 250, 0.15);
--code-bg: #141413; --code-text: #E2E1DC;
--shadow-card: 0 1px 3px rgba(0,0,0,0.25); --shadow-lg: 0 8px 24px rgba(0,0,0,0.45);
}
}
html { scroll-behavior: smooth; }
body {
font-family: var(--ff-body);
background: var(--bg);
color: var(--text-1);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.container { max-width: 800px; margin: 0 auto; padding: 0 20px; }
.container-wide { max-width: 1120px; margin: 0 auto; padding: 0 20px; }
/* Nav */
.nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
height: 56px;
}
.nav .container-wide { display: flex; align-items: center; justify-content: space-between; height: 100%; }
.nav-logo {
display: flex; align-items: center; gap: 10px;
font-family: var(--ff-display); font-weight: 700; font-size: 1.25rem;
color: var(--text-1); text-decoration: none;
}
.nav-logo:hover { text-decoration: none; }
.nav-logo svg { width: 28px; height: 28px; }
.nav-back {
display: flex; align-items: center; gap: 6px;
font-size: 0.8125rem; color: var(--text-2);
text-decoration: none;
padding: 4px 8px; border-radius: 6px;
transition: color 0.15s ease, background 0.15s ease;
}
.nav-back:hover { color: var(--text-1); background: var(--surface); text-decoration: none; }
.nav-back svg { width: 14px; height: 14px; }
.nav-controls { display: flex; align-items: center; gap: 6px; }
.nav-btn {
background: none; border: 1px solid var(--border);
color: var(--text-2); cursor: pointer;
padding: 6px 10px; border-radius: 8px;
font-size: 0.8125rem; font-family: var(--ff-body);
display: flex; align-items: center; gap: 4px;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.nav-btn:hover { background: var(--surface); color: var(--text-1); border-color: var(--text-3); }
.nav-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.nav-btn svg { width: 16px; height: 16px; }
/* Hero */
.hero {
padding: 108px 0 56px;
position: relative; overflow: hidden;
}
.hero::before {
content: ''; position: absolute; top: -80px; left: 50%;
transform: translateX(-50%); width: 700px; height: 500px;
background: radial-gradient(ellipse at center, color-mix(in srgb, var(--accent) 7%, transparent) 0%, transparent 70%);
pointer-events: none; z-index: 0;
}
.hero > .container > * { position: relative; z-index: 1; }
.hero-eyebrow {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--accent);
margin-bottom: 14px;
}
.hero-eyebrow svg { width: 13px; height: 13px; }
.hero h1 {
font-family: var(--ff-display);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 800; line-height: 1.1;
letter-spacing: -0.02em; margin-bottom: 14px;
}
.hero-desc {
font-size: 1.0625rem; color: var(--text-2);
max-width: 540px; line-height: 1.7; margin-bottom: 28px;
}
.time-badge {
display: inline-flex; align-items: center; gap: 8px;
background: var(--success-bg);
border: 1px solid color-mix(in srgb, var(--success) 25%, transparent);
color: var(--success); border-radius: 999px;
padding: 6px 14px; font-size: 0.8125rem; font-weight: 500;
}
.time-badge svg { width: 14px; height: 14px; }
/* Section */
section { padding: 56px 0; }
.section-label {
font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--accent); margin-bottom: 8px;
}
.section-title {
font-family: var(--ff-display);
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 700; line-height: 1.2;
letter-spacing: -0.015em; margin-bottom: 10px;
}
.section-desc { font-size: 1rem; color: var(--text-2); line-height: 1.7; margin-bottom: 32px; }
/* Prerequisites */
.prereq-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.prereq-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.prereq-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-card); }
.prereq-icon {
width: 40px; height: 40px; border-radius: 10px;
background: var(--accent-soft); color: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; margin-bottom: 12px;
}
.prereq-card h3 { font-family: var(--ff-display); font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
.prereq-card p { font-size: 0.8125rem; color: var(--text-2); line-height: 1.5; margin-bottom: 8px; }
.prereq-links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.prereq-link {
font-size: 0.75rem; font-weight: 500; color: var(--accent);
background: var(--accent-soft); border-radius: 6px;
padding: 3px 8px; text-decoration: none;
}
.prereq-link:hover { text-decoration: underline; }
/* Steps */
.steps-section { background: var(--bg-alt); }
.tab-bar {
display: flex; gap: 4px; margin-bottom: 32px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 4px; width: fit-content;
}
.tab-btn {
background: none; border: none; cursor: pointer;
font-family: var(--ff-body); font-size: 0.875rem; font-weight: 500;
color: var(--text-2); padding: 8px 18px; border-radius: 8px;
transition: background 0.15s ease, color 0.15s ease;
}
.tab-btn.active { background: var(--accent); color: #fff; }
.tab-btn:hover:not(.active) { background: var(--bg-alt); color: var(--text-1); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.recommended-badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.05em;
text-transform: uppercase; color: var(--success);
background: var(--success-bg);
border: 1px solid color-mix(in srgb, var(--success) 20%, transparent);
border-radius: 999px; padding: 2px 8px; margin-left: 8px;
vertical-align: middle;
}
.step {
display: grid; grid-template-columns: 48px 1fr;
gap: 0 20px; margin-bottom: 32px; position: relative;
}
.step:not(:last-child)::before {
content: ''; position: absolute;
left: 23px; top: 48px; bottom: -4px; width: 2px;
background: var(--border);
}
.step-num {
width: 48px; height: 48px; border-radius: 50%;
background: var(--accent); color: #fff;
display: flex; align-items: center; justify-content: center;
font-family: var(--ff-display); font-size: 1.125rem; font-weight: 700;
flex-shrink: 0; position: relative; z-index: 1;
box-shadow: var(--shadow-card);
}
.step-content { padding-top: 10px; }
.step-title {
font-family: var(--ff-display); font-size: 1.125rem; font-weight: 600;
margin-bottom: 8px;
}
.step-desc { font-size: 0.9375rem; color: var(--text-2); line-height: 1.65; margin-bottom: 12px; }
.step-desc code {
font-family: var(--font-mono); font-size: 0.8125rem;
background: var(--accent-soft); color: var(--accent);
border-radius: 4px; padding: 1px 5px;
}
/* Code blocks */
.code-wrap { position: relative; margin: 12px 0 4px; }
.code-block {
background: var(--code-bg); color: var(--code-text);
border-radius: var(--radius); padding: 18px 20px;
font-family: var(--font-mono); font-size: 0.8125rem;
line-height: 1.8; overflow-x: auto;
}
.code-block .comment { color: #6C6B67; }
.code-block .cmd { color: #60A5FA; }
.code-block .val { color: #86EFAC; }
.copy-btn {
position: absolute; top: 10px; right: 10px;
background: color-mix(in srgb, #fff 10%, transparent);
border: 1px solid color-mix(in srgb, #fff 15%, transparent);
color: #A8A8A6; cursor: pointer; border-radius: 6px;
padding: 4px 10px; font-size: 0.75rem; font-family: var(--ff-body);
transition: background 0.15s ease, color 0.15s ease;
display: flex; align-items: center; gap: 5px;
}
.copy-btn:hover { background: color-mix(in srgb, #fff 18%, transparent); color: #fff; }
.copy-btn svg { width: 13px; height: 13px; }
.copy-btn.copied { color: #86EFAC; }
/* Callouts */
.callout {
border-radius: var(--radius); padding: 14px 18px;
font-size: 0.875rem; line-height: 1.6; margin: 12px 0;
display: flex; gap: 10px; align-items: flex-start;
}
.callout svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
.callout-warning {
background: var(--warning-bg);
border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent);
color: var(--warning);
}
.callout-info {
background: var(--accent-soft);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.callout-success {
background: var(--success-bg);
border: 1px solid color-mix(in srgb, var(--success) 25%, transparent);
color: var(--success);
}
.callout a { color: inherit; font-weight: 500; text-decoration: underline; }
/* Env vars */
.env-section { }
.env-grid { display: grid; gap: 12px; }
.env-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 18px 20px;
display: grid; grid-template-columns: auto 1fr; gap: 0 16px;
}
.env-required {
border-left: 3px solid var(--accent);
}
.env-name {
font-family: var(--font-mono); font-size: 0.875rem; font-weight: 600;
color: var(--accent); white-space: nowrap;
grid-row: span 2; display: flex; align-items: flex-start;
padding-top: 2px;
}
.env-label { font-size: 0.875rem; font-weight: 600; margin-bottom: 2px; }
.env-desc { font-size: 0.8125rem; color: var(--text-2); line-height: 1.5; }
.env-req-badge {
display: inline-flex; font-size: 0.6875rem; font-weight: 700;
letter-spacing: 0.04em; text-transform: uppercase;
color: var(--accent); margin-left: 6px; vertical-align: middle;
}
/* Optional sections */
.optional-section { background: var(--bg-alt); }
.optional-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.optional-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px;
}
.optional-card h3 { font-family: var(--ff-display); font-size: 1rem; font-weight: 600; margin-bottom: 6px; }
.optional-card p { font-size: 0.8125rem; color: var(--text-2); line-height: 1.55; }
.optional-card a { font-weight: 500; }
/* Troubleshooting */
.trouble-list { display: grid; gap: 8px; }
details {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
}
summary {
padding: 16px 20px; cursor: pointer;
font-weight: 600; font-size: 0.9375rem;
display: flex; align-items: center; justify-content: space-between;
list-style: none; user-select: none;
transition: background 0.15s ease;
}
summary::-webkit-details-marker { display: none; }
summary:hover { background: var(--bg-alt); }
summary::after {
content: ''; width: 16px; height: 16px; flex-shrink: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238E8D89' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat; background-size: 16px;
transition: transform 0.2s ease;
}
details[open] summary::after { transform: rotate(180deg); }
.details-body {
padding: 0 20px 18px;
font-size: 0.875rem; color: var(--text-2); line-height: 1.65;
border-top: 1px solid var(--border);
padding-top: 14px;
}
.details-body code {
font-family: var(--font-mono); font-size: 0.8125rem;
background: var(--accent-soft); color: var(--accent);
border-radius: 4px; padding: 1px 5px;
}
.details-body .code-block { margin: 10px 0; }
/* Success section */
.success-section {
background: var(--bg-alt);
}
.success-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 32px;
text-align: center;
}
.success-icon {
width: 56px; height: 56px; border-radius: 16px;
background: var(--success-bg); color: var(--success);
display: flex; align-items: center; justify-content: center;
margin: 0 auto 16px; font-size: 1.75rem;
}
.success-box h2 {
font-family: var(--ff-display); font-size: 1.5rem; font-weight: 700;
margin-bottom: 8px;
}
.success-box p { color: var(--text-2); font-size: 0.9375rem; margin-bottom: 20px; }
.success-url {
display: inline-flex; align-items: center; gap: 8px;
background: var(--code-bg); color: var(--code-text);
font-family: var(--font-mono); font-size: 0.9375rem;
padding: 12px 20px; border-radius: var(--radius);
margin-bottom: 20px;
}
.success-links { display: flex; justify-content: center; gap: 12px; flex-wrap: wrap; }
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
background: var(--accent); color: #fff;
padding: 10px 20px; border-radius: 10px;
font-weight: 600; font-size: 0.9375rem; text-decoration: none;
transition: background 0.2s ease;
}
.btn-primary:hover { background: var(--accent-hover); text-decoration: none; }
.btn-secondary {
display: inline-flex; align-items: center; gap: 6px;
background: var(--surface); color: var(--text-1);
border: 1px solid var(--border);
padding: 10px 20px; border-radius: 10px;
font-weight: 500; font-size: 0.9375rem; text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease;
}
.btn-secondary:hover { background: var(--bg-alt); border-color: var(--text-3); text-decoration: none; }
/* Footer */
.footer {
border-top: 1px solid var(--border);
padding: 40px 0; text-align: center;
}
.footer-heart { font-size: 0.9375rem; color: var(--text-2); margin-bottom: 12px; }
.footer-links { display: flex; justify-content: center; gap: 24px; font-size: 0.8125rem; }
.footer-links a { color: var(--text-3); }
.footer-links a:hover { color: var(--accent); }
/* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 40px 0; }
/* Reveal */
.reveal { opacity: 1; transform: none; }
.js .reveal { opacity: 0; transform: translateY(16px); transition: opacity 0.5s ease, transform 0.5s ease; }
.js .reveal.visible { opacity: 1; transform: none; }
.reveal-delay-1 { transition-delay: 0.08s; }
.reveal-delay-2 { transition-delay: 0.16s; }
@media (max-width: 640px) {
.hero { padding: 90px 0 40px; }
section { padding: 44px 0; }
.prereq-grid { grid-template-columns: 1fr; }
.step { grid-template-columns: 40px 1fr; gap: 0 14px; }
.step-num { width: 40px; height: 40px; font-size: 1rem; }
.step:not(:last-child)::before { left: 19px; }
.optional-grid { grid-template-columns: 1fr; }
.success-links { flex-direction: column; align-items: center; }
}
</style>
</head>
<body>
<nav class="nav" role="navigation" aria-label="Main">
<div class="container-wide">
<a href="index.html" class="nav-logo" aria-label="Oikos Home">
<svg viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs><linearGradient id="navbg" x1="0" y1="0" x2="160" y2="160" gradientUnits="userSpaceOnUse"><stop offset="0%" stop-color="#8b5cf6"/><stop offset="100%" stop-color="#6c3aed"/></linearGradient></defs>
<rect width="160" height="160" rx="36" fill="url(#navbg)"/>
<path d="M80 36L36 72V120C36 122.2 37.8 124 40 124H68V96H92V124H120C122.2 124 124 122.2 124 120V72L80 36Z" fill="white"/>
<rect x="100" y="46" width="12" height="22" rx="2" fill="white"/>
</svg>
Oikos
</a>
<div class="nav-controls">
<a href="index.html" class="nav-back" aria-label="Back to overview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
<span id="backLabel">Overview</span>
</a>
<button class="nav-btn" id="langToggle" type="button" aria-label="Switch language">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
<span id="langLabel">DE</span>
</button>
<button class="nav-btn" id="themeToggle" type="button" aria-label="Toggle theme">
<svg id="themeIconSun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="themeIconMoon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</div>
</div>
</nav>
<!-- Hero -->
<header class="hero">
<div class="container">
<p class="hero-eyebrow reveal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<span data-t="hero_eyebrow">Installation</span>
</p>
<h1 class="reveal" data-t="hero_title">Install Oikos</h1>
<p class="hero-desc reveal" data-t="hero_desc">Get your self-hosted family planner running in a few minutes. No programming experience required — just Docker.</p>
<div class="reveal">
<span class="time-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span data-t="hero_time">~10 minutes</span>
</span>
</div>
</div>
</header>
<!-- Prerequisites -->
<section id="prerequisites">
<div class="container">
<p class="section-label reveal" data-t="prereq_label">Before you start</p>
<h2 class="section-title reveal" data-t="prereq_title">What you need</h2>
<p class="section-desc reveal" data-t="prereq_desc">Oikos runs as a Docker container — you don't need to install Node.js or any other runtime. Just Docker, and you're good to go.</p>
<div class="prereq-grid">
<div class="prereq-card reveal">
<div class="prereq-icon" aria-hidden="true">🐳</div>
<h3>Docker</h3>
<p data-t="prereq_docker_desc">Packages the app so you don't need to install anything else. Free for personal use.</p>
<div class="prereq-links">
<a href="https://docs.docker.com/desktop/install/mac-install/" target="_blank" rel="noopener" class="prereq-link">macOS</a>
<a href="https://docs.docker.com/desktop/install/windows-install/" target="_blank" rel="noopener" class="prereq-link">Windows</a>
<a href="https://docs.docker.com/engine/install/" target="_blank" rel="noopener" class="prereq-link">Linux</a>
</div>
</div>
<div class="prereq-card reveal reveal-delay-1">
<div class="prereq-icon" aria-hidden="true">💻</div>
<h3 data-t="prereq_terminal_title">Terminal</h3>
<p data-t="prereq_terminal_desc">A command-line interface to type a few commands. Built into every OS — no extra install needed.</p>
<div class="prereq-links">
<span style="font-size:0.75rem;color:var(--text-3)" data-t="prereq_terminal_hint">macOS: Terminal · Windows: PowerShell · Linux: bash</span>
</div>
</div>
<div class="prereq-card reveal reveal-delay-2">
<div class="prereq-icon" aria-hidden="true">⚙️</div>
<h3 data-t="prereq_sys_title">System</h3>
<p data-t="prereq_sys_desc">256 MB RAM minimum. Runs on a Raspberry Pi, NAS, home server, or any desktop machine.</p>
<div class="prereq-links">
<span style="font-size:0.75rem;color:var(--text-3)" data-t="prereq_sys_hint">~500 MB disk for Docker image</span>
</div>
</div>
</div>
</div>
</section>
<!-- Steps -->
<section class="steps-section" id="install">
<div class="container">
<p class="section-label reveal" data-t="steps_label">Step by step</p>
<h2 class="section-title reveal" data-t="steps_title">Installation</h2>
<div class="tab-bar reveal" role="tablist" aria-label="Installation options">
<button class="tab-btn active" role="tab" aria-selected="true" aria-controls="panel-installer" id="tab-installer" data-t="tab_installer">
Option A — Web Installer
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-a" id="tab-a" data-t="tab_a">
Option B — Pre-built Image
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-b" id="tab-b" data-t="tab_b">
Option C — Build from Source
</button>
</div>
<!-- Option A — Web Installer -->
<div class="tab-panel active" id="panel-installer" role="tabpanel" aria-labelledby="tab-installer">
<div class="callout callout-success reveal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<span data-t="option_installer_info">Recommended for most users. A browser-based wizard configures your .env, starts Docker, and creates your admin account — no manual steps. Requires Node.js 18+ on the host.</span>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">1</div>
<div class="step-content">
<h3 class="step-title" data-t="step_inst1_title">Clone the repository</h3>
<p class="step-desc" data-t="step_inst1_desc">Open your terminal and clone Oikos to a folder of your choice.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Clone repo">git clone https://github.com/ulsklyc/oikos.git
cd oikos</div>
<button class="copy-btn" data-copy="git clone https://github.com/ulsklyc/oikos.git&#10;cd oikos" aria-label="Copy commands">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
</div>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">2</div>
<div class="step-content">
<h3 class="step-title" data-t="step_inst2_title">Start the installer</h3>
<p class="step-desc" data-t="step_inst2_desc">Run this command from the repository root. The installer server starts on port 8090.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Start installer"><span class="cmd">node tools/installer/install-server.js</span></div>
<button class="copy-btn" data-copy="node tools/installer/install-server.js" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
</div>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">3</div>
<div class="step-content">
<h3 class="step-title" data-t="step_inst3_title">Open the wizard in your browser</h3>
<p class="step-desc" data-t="step_inst3_desc">Navigate to the following address. The wizard will guide you through configuration, Docker startup, and admin account creation.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Installer URL"><span class="val">http://localhost:8090</span></div>
<button class="copy-btn" data-copy="http://localhost:8090" aria-label="Copy URL">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
<div class="callout callout-info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span data-t="step_inst3_info">The installer shuts down automatically after setup completes. Your Oikos instance keeps running via Docker.</span>
</div>
</div>
</div>
</div>
<!-- Option B — Pre-built Image -->
<div class="tab-panel" id="panel-a" role="tabpanel" aria-labelledby="tab-a">
<div class="callout callout-info reveal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span data-t="option_a_info">No Git, no build step — just two files and a single command. Requires only Docker.</span>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">1</div>
<div class="step-content">
<h3 class="step-title" data-t="step_a1_title">Download the configuration files</h3>
<p class="step-desc" data-t="step_a1_desc">Open your terminal and run these two commands. They download the Docker configuration and the template for your settings.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Download commands">curl -O https://raw.githubusercontent.com/ulsklyc/oikos/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/ulsklyc/oikos/main/.env.example</div>
<button class="copy-btn" data-copy="curl -O https://raw.githubusercontent.com/ulsklyc/oikos/main/docker-compose.yml&#10;curl -O https://raw.githubusercontent.com/ulsklyc/oikos/main/.env.example" aria-label="Copy commands">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
</div>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">2</div>
<div class="step-content">
<h3 class="step-title" data-t="step_a2_title">Create your configuration</h3>
<p class="step-desc" data-t="step_a2_desc">Copy the template to create your own settings file. Then open <code>.env</code> in a text editor and set the two required secrets.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Setup env">cp .env.example .env</div>
<button class="copy-btn" data-copy="cp .env.example .env" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
<p class="step-desc" data-t="step_a2_gen">Generate a secure value for each secret by running this command twice — paste one result as <code>SESSION_SECRET</code> and one as <code>DB_ENCRYPTION_KEY</code>:</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Generate secret">openssl rand -hex 32</div>
<button class="copy-btn" data-copy="openssl rand -hex 32" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
<div class="callout callout-warning">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span data-t="step_a2_warning">Keep a backup of your <code>.env</code> file somewhere safe. If you lose the <code>DB_ENCRYPTION_KEY</code>, your data cannot be recovered.</span>
</div>
</div>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">3</div>
<div class="step-content">
<h3 class="step-title" data-t="step_a3_title">Start the container</h3>
<p class="step-desc" data-t="step_a3_desc">Docker will automatically download the Oikos image and start it in the background. The first download takes a minute.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Start container"><span class="cmd">docker compose up -d</span></div>
<button class="copy-btn" data-copy="docker compose up -d" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
<p class="step-desc" data-t="step_a3_verify">You can verify it's running by checking the logs:</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Check logs">docker compose logs -f</div>
<button class="copy-btn" data-copy="docker compose logs -f" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
<div class="callout callout-success">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<span data-t="step_a3_success">You should see: <code>Server läuft auf Port 3000</code>. Press Ctrl+C to stop following logs — the container keeps running.</span>
</div>
</div>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">4</div>
<div class="step-content">
<h3 class="step-title" data-t="step_setup_title">Create your admin account</h3>
<p class="step-desc" data-t="step_setup_desc">Run the interactive setup wizard to create the first user account. You'll be asked for a username, display name, and password.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Run setup"><span class="cmd">docker compose exec oikos node setup.js</span></div>
<button class="copy-btn" data-copy="docker compose exec oikos node setup.js" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
</div>
</div>
</div>
<!-- Option C — Build from Source -->
<div class="tab-panel" id="panel-b" role="tabpanel" aria-labelledby="tab-b">
<div class="callout callout-info reveal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span data-t="option_b_info">For contributors or those who want to run a custom version. Requires Git. The first build takes a few minutes.</span>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">1</div>
<div class="step-content">
<h3 class="step-title" data-t="step_b1_title">Clone the repository</h3>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Clone repo">git clone https://github.com/ulsklyc/oikos.git
cd oikos</div>
<button class="copy-btn" data-copy="git clone https://github.com/ulsklyc/oikos.git&#10;cd oikos" aria-label="Copy commands">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
</div>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">2</div>
<div class="step-content">
<h3 class="step-title" data-t="step_a2_title">Create your configuration</h3>
<p class="step-desc" data-t="step_a2_desc">Copy the template to create your own settings file. Then open <code>.env</code> in a text editor and set the two required secrets.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Setup env">cp .env.example .env</div>
<button class="copy-btn" data-copy="cp .env.example .env" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
<p class="step-desc" data-t="step_a2_gen">Generate a secure value for each secret — run this twice:</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Generate secret">openssl rand -hex 32</div>
<button class="copy-btn" data-copy="openssl rand -hex 32" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
</div>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">3</div>
<div class="step-content">
<h3 class="step-title" data-t="step_b3_title">Build and start</h3>
<p class="step-desc" data-t="step_b3_desc">The <code>--build</code> flag compiles the Docker image locally. This takes a few minutes the first time.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Build and start"><span class="cmd">docker compose up -d --build</span></div>
<button class="copy-btn" data-copy="docker compose up -d --build" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
</div>
</div>
<div class="step reveal">
<div class="step-num" aria-hidden="true">4</div>
<div class="step-content">
<h3 class="step-title" data-t="step_setup_title">Create your admin account</h3>
<p class="step-desc" data-t="step_setup_desc">Run the interactive setup wizard to create the first user account. You'll be asked for a username, display name, and password.</p>
<div class="code-wrap">
<div class="code-block" role="region" aria-label="Run setup"><span class="cmd">docker compose exec oikos node setup.js</span></div>
<button class="copy-btn" data-copy="docker compose exec oikos node setup.js" aria-label="Copy command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span data-t="copy">Copy</span>
</button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Open Oikos -->
<section class="success-section" id="open">
<div class="container">
<div class="success-box reveal">
<div class="success-icon" aria-hidden="true">🎉</div>
<h2 data-t="success_title">You're all set!</h2>
<p data-t="success_desc">Open your browser and navigate to:</p>
<div class="success-url" aria-label="App URL">http://localhost:3000</div>
<p style="font-size:0.875rem;color:var(--text-2);margin-bottom:20px" data-t="success_hint">Log in with the admin credentials you just created. You can add more family members from the Settings page.</p>
<div class="success-links">
<a href="https://github.com/ulsklyc/oikos" target="_blank" rel="noopener" class="btn-primary">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
GitHub
</a>
<a href="https://github.com/ulsklyc/oikos/blob/main/docs/installation.md" target="_blank" rel="noopener" class="btn-secondary" data-t="success_full_guide">Full Technical Guide</a>
</div>
</div>
</div>
</section>
<!-- Key env vars -->
<section class="env-section" id="env">
<div class="container">
<p class="section-label reveal" data-t="env_label">Configuration</p>
<h2 class="section-title reveal" data-t="env_title">Required settings</h2>
<p class="section-desc reveal" data-t="env_desc">These two variables in your <code style="font-family:var(--font-mono);font-size:0.875rem;background:var(--accent-soft);color:var(--accent);border-radius:4px;padding:1px 5px">.env</code> file are mandatory. Everything else is optional.</p>
<div class="env-grid">
<div class="env-card env-required reveal">
<div class="env-name">SESSION_SECRET</div>
<div class="env-label" data-t="env_session_label">Session Secret <span class="env-req-badge">Required</span></div>
<div class="env-desc" data-t="env_session_desc">Signs and verifies login cookies. Use <code>openssl rand -hex 32</code> to generate a secure value.</div>
</div>
<div class="env-card env-required reveal reveal-delay-1">
<div class="env-name">DB_ENCRYPTION_KEY</div>
<div class="env-label" data-t="env_db_label">Database Key <span class="env-req-badge">Required</span></div>
<div class="env-desc" data-t="env_db_desc">Encrypts your entire database with AES-256. Generate with <code>openssl rand -hex 32</code>. Back this up — without it, data is unrecoverable.</div>
</div>
</div>
</div>
</section>
<!-- Optional -->
<section class="optional-section" id="optional">
<div class="container">
<p class="section-label reveal" data-t="optional_label">Optional</p>
<h2 class="section-title reveal" data-t="optional_title">Go further</h2>
<p class="section-desc reveal" data-t="optional_desc">Once Oikos is running, you can set up these extras. All are configured in your <code style="font-family:var(--font-mono);font-size:0.875rem;background:var(--accent-soft);color:var(--accent);border-radius:4px;padding:1px 5px">.env</code> file.</p>
<div class="optional-grid">
<div class="optional-card reveal">
<h3 data-t="opt_https_title">HTTPS &amp; Network Access</h3>
<p data-t="opt_https_desc">Want to reach Oikos from other devices or the internet? Set up Nginx as a reverse proxy with a free Let's Encrypt SSL certificate. <a href="https://github.com/ulsklyc/oikos/blob/main/docs/installation.md#https--reverse-proxy-nginx" target="_blank" rel="noopener" data-t="opt_guide">Guide →</a></p>
</div>
<div class="optional-card reveal reveal-delay-1">
<h3 data-t="opt_weather_title">Weather Widget</h3>
<p data-t="opt_weather_desc">Show the local weather on the dashboard. Set <code>OPENWEATHER_API_KEY</code> and <code>OPENWEATHER_CITY</code> — free API key from <a href="https://openweathermap.org/api" target="_blank" rel="noopener">openweathermap.org</a>.</p>
</div>
<div class="optional-card reveal reveal-delay-2">
<h3 data-t="opt_cal_title">Calendar Sync</h3>
<p data-t="opt_cal_desc">Two-way sync with Google Calendar (OAuth) and Apple iCloud (CalDAV). Set the relevant <code>GOOGLE_*</code> or <code>APPLE_*</code> variables. <a href="https://github.com/ulsklyc/oikos/blob/main/docs/installation.md#environment-variables" target="_blank" rel="noopener" data-t="opt_guide">Guide →</a></p>
</div>
<div class="optional-card reveal reveal-delay-1">
<h3 data-t="opt_backup_title">Automated Backups</h3>
<p data-t="opt_backup_desc">Add a daily cron job to back up your database. All data lives in the <code>oikos_data</code> Docker volume. <a href="https://github.com/ulsklyc/oikos/blob/main/docs/installation.md#backup--restore" target="_blank" rel="noopener" data-t="opt_guide">Guide →</a></p>
</div>
<div class="optional-card reveal reveal-delay-2">
<h3 data-t="opt_update_title">Updates</h3>
<p data-t="opt_update_desc">Pull the latest image and restart: <code>docker compose pull &amp;&amp; docker compose up -d</code>. Your data persists across updates.</p>
</div>
</div>
</div>
</section>
<!-- Troubleshooting -->
<section id="troubleshooting">
<div class="container">
<p class="section-label reveal" data-t="trouble_label">Troubleshooting</p>
<h2 class="section-title reveal" data-t="trouble_title">Something not working?</h2>
<p class="section-desc reveal" data-t="trouble_desc">Most issues have a simple fix. Check below — if you're still stuck, open an issue on GitHub.</p>
<div class="trouble-list reveal">
<details>
<summary data-t="trouble_port_title">Port 3000 is already in use</summary>
<div class="details-body">
<p data-t="trouble_port_desc">Another application is using port 3000. Either stop the conflicting process, or change the port in <code>docker-compose.yml</code>:</p>
<div class="code-wrap">
<div class="code-block">lsof -i :3000 <span class="comment"># find what's using the port</span></div>
</div>
<p data-t="trouble_port_change">Or edit <code>docker-compose.yml</code> and change <code>3000:3000</code> to e.g. <code>8080:3000</code>.</p>
</div>
</details>
<details>
<summary data-t="trouble_perm_title">Docker: Permission denied</summary>
<div class="details-body">
<p data-t="trouble_perm_desc">Add your user to the Docker group, then log out and back in:</p>
<div class="code-wrap">
<div class="code-block">sudo usermod -aG docker $USER</div>
</div>
</div>
</details>
<details>
<summary data-t="trouble_reach_title">Container starts but the page is not reachable</summary>
<div class="details-body">
<p data-t="trouble_reach_desc">Check the container status and logs:</p>
<div class="code-wrap">
<div class="code-block">docker compose ps <span class="comment"># should show "Up" and "healthy"</span>
docker compose logs <span class="comment"># look for error messages</span>
docker port oikos <span class="comment"># verify port mapping</span></div>
</div>
<p data-t="trouble_reach_firewall">Accessing from another device? Check your firewall rules.</p>
</div>
</details>
<details>
<summary data-t="trouble_db_title">Database encryption error</summary>
<div class="details-body">
<p data-t="trouble_db_desc">The <code>DB_ENCRYPTION_KEY</code> in your <code>.env</code> is missing or doesn't match the key used when the database was created. If this is a fresh install, you can reset:</p>
<div class="code-wrap">
<div class="code-block">docker compose down -v
<span class="cmd">docker compose up -d</span></div>
</div>
<div class="callout callout-warning" style="margin-top:10px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span data-t="trouble_db_warning"><code>docker compose down -v</code> deletes all data. Only use this on a fresh install with no data.</span>
</div>
</div>
</details>
<details>
<summary data-t="trouble_nginx_title">Nginx shows 502 Bad Gateway</summary>
<div class="details-body">
<p data-t="trouble_nginx_desc">Nginx can't reach the container. Check that it's running and the port matches:</p>
<div class="code-wrap">
<div class="code-block">docker compose ps
docker compose logs | grep "Server läuft"</div>
</div>
<p data-t="trouble_nginx_port">Ensure the <code>proxy_pass</code> port in your Nginx config matches the host port in <code>docker-compose.yml</code> (default: 3000).</p>
</div>
</details>
</div>
</div>
</section>
<footer class="footer">
<div class="container">
<p class="footer-heart" data-t="footer_heart">Built with care for families who value privacy and simplicity.</p>
<div class="footer-links">
<a href="index.html" data-t="footer_home">Home</a>
<a href="https://github.com/ulsklyc/oikos" target="_blank" rel="noopener">GitHub</a>
<a href="https://github.com/ulsklyc/oikos/blob/main/LICENSE" target="_blank" rel="noopener">MIT License</a>
<a href="https://github.com/ulsklyc/oikos/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener" data-t="footer_contrib">Contributing</a>
</div>
</div>
</footer>
<script>
(function() {
'use strict';
var i18n = {
en: {
hero_eyebrow: 'Installation',
hero_title: 'Install Oikos',
hero_desc: 'Get your self-hosted family planner running in a few minutes. No programming experience required \u2014 just Docker.',
hero_time: '~10 minutes',
prereq_label: 'Before you start',
prereq_title: 'What you need',
prereq_desc: 'Oikos runs as a Docker container \u2014 you don\u2019t need to install Node.js or any other runtime. Just Docker, and you\u2019re good to go.',
prereq_docker_desc: 'Packages the app so you don\u2019t need to install anything else. Free for personal use.',
prereq_terminal_title: 'Terminal',
prereq_terminal_desc: 'A command-line interface to type a few commands. Built into every OS \u2014 no extra install needed.',
prereq_terminal_hint: 'macOS: Terminal \u00b7 Windows: PowerShell \u00b7 Linux: bash',
prereq_sys_title: 'System',
prereq_sys_desc: '256 MB RAM minimum. Runs on a Raspberry Pi, NAS, home server, or any desktop machine.',
prereq_sys_hint: '~500 MB disk for Docker image',
steps_label: 'Step by step',
steps_title: 'Installation',
tab_installer: 'Option A \u2014 Web Installer',
tab_a: 'Option B \u2014 Pre-built Image',
tab_b: 'Option C \u2014 Build from Source',
option_installer_info: 'Recommended for most users. A browser-based wizard configures your .env, starts Docker, and creates your admin account \u2014 no manual steps. Requires Node.js 18+ on the host.',
option_a_info: 'No Git, no build step \u2014 just two files and a single command. Requires only Docker.',
option_b_info: 'For contributors or those who want to run a custom version. Requires Git. The first build takes a few minutes.',
step_inst1_title: 'Clone the repository',
step_inst1_desc: 'Open your terminal and clone Oikos to a folder of your choice.',
step_inst2_title: 'Start the installer',
step_inst2_desc: 'Run this command from the repository root. The installer server starts on port 8090.',
step_inst3_title: 'Open the wizard in your browser',
step_inst3_desc: 'Navigate to the following address. The wizard will guide you through configuration, Docker startup, and admin account creation.',
step_inst3_info: 'The installer shuts down automatically after setup completes. Your Oikos instance keeps running via Docker.',
step_a1_title: 'Download the configuration files',
step_a1_desc: 'Open your terminal and run these two commands. They download the Docker configuration and the template for your settings.',
step_a2_title: 'Create your configuration',
step_a2_desc: 'Copy the template to create your own settings file. Then open .env in a text editor and set the two required secrets.',
step_a2_gen: 'Generate a secure value for each secret by running this command twice \u2014 paste one result as SESSION_SECRET and one as DB_ENCRYPTION_KEY:',
step_a2_warning: 'Keep a backup of your .env file somewhere safe. If you lose the DB_ENCRYPTION_KEY, your data cannot be recovered.',
step_a3_title: 'Start the container',
step_a3_desc: 'Docker will automatically download the Oikos image and start it in the background. The first download takes a minute.',
step_a3_verify: 'You can verify it\u2019s running by checking the logs:',
step_a3_success: 'You should see: Server l\u00e4uft auf Port 3000. Press Ctrl+C to stop following logs \u2014 the container keeps running.',
step_b1_title: 'Clone the repository',
step_b3_title: 'Build and start',
step_b3_desc: 'The --build flag compiles the Docker image locally. This takes a few minutes the first time.',
step_setup_title: 'Create your admin account',
step_setup_desc: 'Run the interactive setup wizard to create the first user account. You\u2019ll be asked for a username, display name, and password.',
success_title: 'You\u2019re all set!',
success_desc: 'Open your browser and navigate to:',
success_hint: 'Log in with the admin credentials you just created. You can add more family members from the Settings page.',
success_full_guide: 'Full Technical Guide',
env_label: 'Configuration',
env_title: 'Required settings',
env_desc: 'These two variables in your .env file are mandatory. Everything else is optional.',
env_session_label: 'Session Secret',
env_session_desc: 'Signs and verifies login cookies. Use openssl rand -hex 32 to generate a secure value.',
env_db_label: 'Database Key',
env_db_desc: 'Encrypts your entire database with AES-256. Generate with openssl rand -hex 32. Back this up \u2014 without it, data is unrecoverable.',
optional_label: 'Optional',
optional_title: 'Go further',
optional_desc: 'Once Oikos is running, you can set up these extras. All are configured in your .env file.',
opt_https_title: 'HTTPS & Network Access',
opt_https_desc: 'Want to reach Oikos from other devices or the internet? Set up Nginx as a reverse proxy with a free Let\u2019s Encrypt SSL certificate.',
opt_guide: 'Guide \u2192',
opt_weather_title: 'Weather Widget',
opt_weather_desc: 'Show the local weather on the dashboard. Set OPENWEATHER_API_KEY and OPENWEATHER_CITY \u2014 free API key from openweathermap.org.',
opt_cal_title: 'Calendar Sync',
opt_cal_desc: 'Two-way sync with Google Calendar (OAuth) and Apple iCloud (CalDAV). Set the relevant GOOGLE_* or APPLE_* variables.',
opt_backup_title: 'Automated Backups',
opt_backup_desc: 'Add a daily cron job to back up your database. All data lives in the oikos_data Docker volume.',
opt_update_title: 'Updates',
opt_update_desc: 'Pull the latest image and restart: docker compose pull && docker compose up -d. Your data persists across updates.',
trouble_label: 'Troubleshooting',
trouble_title: 'Something not working?',
trouble_desc: 'Most issues have a simple fix. Check below \u2014 if you\u2019re still stuck, open an issue on GitHub.',
trouble_port_title: 'Port 3000 is already in use',
trouble_port_desc: 'Another application is using port 3000. Either stop the conflicting process, or change the port in docker-compose.yml:',
trouble_port_change: 'Or edit docker-compose.yml and change 3000:3000 to e.g. 8080:3000.',
trouble_perm_title: 'Docker: Permission denied',
trouble_perm_desc: 'Add your user to the Docker group, then log out and back in:',
trouble_reach_title: 'Container starts but the page is not reachable',
trouble_reach_desc: 'Check the container status and logs:',
trouble_reach_firewall: 'Accessing from another device? Check your firewall rules.',
trouble_db_title: 'Database encryption error',
trouble_db_desc: 'The DB_ENCRYPTION_KEY in your .env is missing or doesn\u2019t match the key used when the database was created. If this is a fresh install, you can reset:',
trouble_db_warning: 'docker compose down -v deletes all data. Only use this on a fresh install with no data.',
trouble_nginx_title: 'Nginx shows 502 Bad Gateway',
trouble_nginx_desc: 'Nginx can\u2019t reach the container. Check that it\u2019s running and the port matches:',
trouble_nginx_port: 'Ensure the proxy_pass port in your Nginx config matches the host port in docker-compose.yml (default: 3000).',
footer_home: 'Home',
footer_heart: 'Built with care for families who value privacy and simplicity.',
footer_contrib: 'Contributing',
backLabel: 'Overview',
copy: 'Copy',
copied: 'Copied!'
},
de: {
hero_eyebrow: 'Installation',
hero_title: 'Oikos installieren',
hero_desc: 'Euren selbstgehosteten Familienplaner in wenigen Minuten zum Laufen bringen. Keine Programmierkenntnisse n\u00f6tig \u2014 nur Docker.',
hero_time: '~10 Minuten',
prereq_label: 'Voraussetzungen',
prereq_title: 'Was ihr braucht',
prereq_desc: 'Oikos l\u00e4uft als Docker-Container \u2014 Node.js oder andere Laufzeitumgebungen m\u00fcsst ihr nicht installieren. Nur Docker, und ihr seid startklar.',
prereq_docker_desc: 'Verpackt die App, sodass ihr nichts weiter installieren m\u00fcsst. F\u00fcr den privaten Gebrauch kostenlos.',
prereq_terminal_title: 'Terminal',
prereq_terminal_desc: 'Eine Befehlszeile, um ein paar Befehle einzugeben. In jedem Betriebssystem eingebaut \u2014 kein Extra-Install n\u00f6tig.',
prereq_terminal_hint: 'macOS: Terminal \u00b7 Windows: PowerShell \u00b7 Linux: bash',
prereq_sys_title: 'System',
prereq_sys_desc: 'Mindestens 256 MB RAM. L\u00e4uft auf einem Raspberry Pi, NAS, Heimserver oder jedem Desktop-Rechner.',
prereq_sys_hint: '~500 MB Speicher f\u00fcr das Docker-Image',
steps_label: 'Schritt f\u00fcr Schritt',
steps_title: 'Installation',
tab_installer: 'Option A \u2014 Web-Installer',
tab_a: 'Option B \u2014 Fertiges Image',
tab_b: 'Option C \u2014 Aus Quellcode bauen',
option_installer_info: 'Empfohlen f\u00fcr die meisten Nutzer. Ein browserbasierter Assistent konfiguriert eure .env, startet Docker und erstellt euer Admin-Konto \u2014 ganz ohne manuelle Schritte. Erfordert Node.js 18+ auf dem Host.',
option_a_info: 'Kein Git, kein Build-Schritt \u2014 nur zwei Dateien und ein einziger Befehl. Nur Docker erforderlich.',
option_b_info: 'F\u00fcr Mitwirkende oder wer eine eigene Version ausf\u00fchren m\u00f6chte. Erfordert Git. Der erste Build dauert einige Minuten.',
step_inst1_title: 'Repository klonen',
step_inst1_desc: '\u00d6ffnet euer Terminal und klont Oikos in einen Ordner eurer Wahl.',
step_inst2_title: 'Installer starten',
step_inst2_desc: 'F\u00fchrt diesen Befehl vom Projektordner aus. Der Installer-Server startet auf Port 8090.',
step_inst3_title: 'Assistenten im Browser \u00f6ffnen',
step_inst3_desc: 'Navigiert zur folgenden Adresse. Der Assistent f\u00fchrt euch durch Konfiguration, Docker-Start und Admin-Konto-Erstellung.',
step_inst3_info: 'Der Installer beendet sich automatisch nach Abschluss der Einrichtung. Eure Oikos-Instanz l\u00e4uft weiter \u00fcber Docker.',
step_a1_title: 'Konfigurationsdateien herunterladen',
step_a1_desc: '\u00d6ffnet euer Terminal und f\u00fchrt diese zwei Befehle aus. Sie laden die Docker-Konfiguration und die Vorlage f\u00fcr eure Einstellungen herunter.',
step_a2_title: 'Konfiguration erstellen',
step_a2_desc: 'Kopiert die Vorlage, um eure eigene Einstellungsdatei zu erstellen. \u00d6ffnet dann .env in einem Texteditor und setzt die zwei erforderlichen Secrets.',
step_a2_gen: 'Generiert einen sicheren Wert f\u00fcr jedes Secret, indem ihr diesen Befehl zweimal ausf\u00fchrt \u2014 ein Ergebnis als SESSION_SECRET, eines als DB_ENCRYPTION_KEY:',
step_a2_warning: 'Behaltet eine Sicherungskopie eurer .env-Datei an einem sicheren Ort. Wenn ihr den DB_ENCRYPTION_KEY verliert, k\u00f6nnen eure Daten nicht wiederhergestellt werden.',
step_a3_title: 'Container starten',
step_a3_desc: 'Docker l\u00e4dt automatisch das Oikos-Image herunter und startet es im Hintergrund. Der erste Download dauert eine Minute.',
step_a3_verify: 'Ihr k\u00f6nnt \u00fcberpr\u00fcfen, ob alles l\u00e4uft, indem ihr die Logs ansieht:',
step_a3_success: 'Ihr solltet sehen: Server l\u00e4uft auf Port 3000. Ctrl+C stoppt das Log-Verfolgen \u2014 der Container l\u00e4uft weiter.',
step_b1_title: 'Repository klonen',
step_b3_title: 'Bauen und starten',
step_b3_desc: 'Das Flag --build kompiliert das Docker-Image lokal. Das dauert beim ersten Mal einige Minuten.',
step_setup_title: 'Admin-Konto erstellen',
step_setup_desc: 'F\u00fchrt den interaktiven Einrichtungsassistenten aus, um das erste Benutzerkonto zu erstellen. Ihr werdet nach Benutzername, Anzeigename und Passwort gefragt.',
success_title: 'Alles bereit!',
success_desc: '\u00d6ffnet euren Browser und geht zu:',
success_hint: 'Meldet euch mit den Admin-Zugangsdaten an, die ihr gerade erstellt habt. Weitere Familienmitglieder k\u00f6nnt ihr auf der Einstellungsseite hinzuf\u00fcgen.',
success_full_guide: 'Technische Vollst\u00e4ndiganleitung',
env_label: 'Konfiguration',
env_title: 'Pflichteinstellungen',
env_desc: 'Diese zwei Variablen in eurer .env-Datei sind Pflicht. Alles andere ist optional.',
env_session_label: 'Session-Secret',
env_session_desc: 'Signiert und verifiziert Anmelde-Cookies. Mit openssl rand -hex 32 einen sicheren Wert generieren.',
env_db_label: 'Datenbank-Schl\u00fcssel',
env_db_desc: 'Verschl\u00fcsselt die gesamte Datenbank mit AES-256. Mit openssl rand -hex 32 generieren. Unbedingt sichern \u2014 ohne diesen Schl\u00fcssel sind die Daten nicht wiederherstellbar.',
optional_label: 'Optional',
optional_title: 'Mehr entdecken',
optional_desc: 'Sobald Oikos l\u00e4uft, k\u00f6nnt ihr diese Extras einrichten. Alles wird in eurer .env-Datei konfiguriert.',
opt_https_title: 'HTTPS & Netzwerkzugang',
opt_https_desc: 'Oikos von anderen Ger\u00e4ten oder aus dem Internet erreichbar machen? Nginx als Reverse Proxy mit kostenlosem Let\u2019s Encrypt SSL-Zertifikat einrichten.',
opt_guide: 'Anleitung \u2192',
opt_weather_title: 'Wetter-Widget',
opt_weather_desc: 'Lokales Wetter auf dem Dashboard anzeigen. OPENWEATHER_API_KEY und OPENWEATHER_CITY setzen \u2014 kostenloser API-Key von openweathermap.org.',
opt_cal_title: 'Kalender-Sync',
opt_cal_desc: 'Zwei-Wege-Sync mit Google Calendar (OAuth) und Apple iCloud (CalDAV). Die entsprechenden GOOGLE_*- oder APPLE_*-Variablen setzen.',
opt_backup_title: 'Automatische Backups',
opt_backup_desc: 'Einen t\u00e4glichen Cron-Job einrichten, um die Datenbank zu sichern. Alle Daten liegen im oikos_data Docker-Volume.',
opt_update_title: 'Updates',
opt_update_desc: 'Neuestes Image holen und neu starten: docker compose pull && docker compose up -d. Eure Daten bleiben bei Updates erhalten.',
trouble_label: 'Probleml\u00f6sung',
trouble_title: 'Etwas funktioniert nicht?',
trouble_desc: 'Die meisten Probleme haben eine einfache L\u00f6sung. Schaut unten nach \u2014 wenn ihr weiterhin Schwierigkeiten habt, \u00f6ffnet ein Issue auf GitHub.',
trouble_port_title: 'Port 3000 ist bereits belegt',
trouble_port_desc: 'Eine andere Anwendung nutzt Port 3000. Entweder den Konfliktprozess stoppen oder den Port in docker-compose.yml \u00e4ndern:',
trouble_port_change: 'Oder docker-compose.yml bearbeiten und 3000:3000 zu z.\u00a0B. 8080:3000 \u00e4ndern.',
trouble_perm_title: 'Docker: Permission denied',
trouble_perm_desc: 'Euren Benutzer zur Docker-Gruppe hinzuf\u00fcgen, dann ab- und wieder anmelden:',
trouble_reach_title: 'Container startet, aber die Seite ist nicht erreichbar',
trouble_reach_desc: 'Container-Status und Logs pr\u00fcfen:',
trouble_reach_firewall: 'Zugriff von einem anderen Ger\u00e4t? Firewall-Regeln pr\u00fcfen.',
trouble_db_title: 'Datenbank-Verschl\u00fcsselungsfehler',
trouble_db_desc: 'Der DB_ENCRYPTION_KEY in eurer .env fehlt oder stimmt nicht mit dem Schl\u00fcssel \u00fcberein, der beim Erstellen der Datenbank verwendet wurde. Bei einer Neuinstallation k\u00f6nnt ihr zur\u00fccksetzen:',
trouble_db_warning: 'docker compose down -v l\u00f6scht alle Daten. Nur bei einer Neuinstallation ohne Daten verwenden.',
trouble_nginx_title: 'Nginx zeigt 502 Bad Gateway',
trouble_nginx_desc: 'Nginx kann den Container nicht erreichen. Pr\u00fcfen, ob er l\u00e4uft und der Port stimmt:',
trouble_nginx_port: 'Sicherstellen, dass der proxy_pass-Port in der Nginx-Konfiguration mit dem Host-Port in docker-compose.yml \u00fcbereinstimmt (Standard: 3000).',
footer_home: 'Startseite',
footer_heart: 'Mit Sorgfalt gebaut f\u00fcr Familien, die Privatsph\u00e4re und Einfachheit sch\u00e4tzen.',
footer_contrib: 'Mitmachen',
backLabel: '\u00dcbersicht',
copy: 'Kopieren',
copied: 'Kopiert!'
}
};
var currentLang = localStorage.getItem('oikos-lang') || 'en';
var currentTheme = localStorage.getItem('oikos-theme');
/* Theme */
function isDark() {
if (currentTheme === 'dark') return true;
if (currentTheme === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyTheme() {
var dark = isDark();
if (currentTheme) {
document.documentElement.setAttribute('data-theme', currentTheme);
} else {
document.documentElement.removeAttribute('data-theme');
}
document.getElementById('themeIconSun').style.display = dark ? 'none' : 'block';
document.getElementById('themeIconMoon').style.display = dark ? 'block' : 'none';
}
document.getElementById('themeToggle').addEventListener('click', function() {
currentTheme = isDark() ? 'light' : 'dark';
localStorage.setItem('oikos-theme', currentTheme);
applyTheme();
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
if (!currentTheme) applyTheme();
});
/* Language */
function applyLang() {
document.documentElement.lang = currentLang;
var strings = i18n[currentLang];
document.querySelectorAll('[data-t]').forEach(function(el) {
var key = el.getAttribute('data-t');
if (strings[key] !== undefined) el.textContent = strings[key];
});
document.getElementById('langLabel').textContent = currentLang === 'en' ? 'DE' : 'EN';
document.getElementById('backLabel').textContent = strings.backLabel;
document.title = currentLang === 'en'
? 'Install Oikos \u2014 Self-Hosted Family Planner'
: 'Oikos installieren \u2014 Selbstgehosteter Familienplaner';
}
document.getElementById('langToggle').addEventListener('click', function() {
currentLang = currentLang === 'en' ? 'de' : 'en';
localStorage.setItem('oikos-lang', currentLang);
applyLang();
});
/* Tabs */
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var panelId = btn.getAttribute('aria-controls');
document.querySelectorAll('.tab-btn').forEach(function(b) {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
document.getElementById(panelId).classList.add('active');
});
});
/* Copy buttons */
document.querySelectorAll('.copy-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var text = btn.getAttribute('data-copy');
if (!text) return;
navigator.clipboard.writeText(text).then(function() {
var span = btn.querySelector('span');
var orig = span.textContent;
btn.classList.add('copied');
span.textContent = i18n[currentLang].copied || 'Copied!';
setTimeout(function() {
btn.classList.remove('copied');
span.textContent = i18n[currentLang].copy || 'Copy';
}, 2000);
});
});
});
/* Scroll Reveal */
document.documentElement.classList.add('js');
if ('IntersectionObserver' in window) {
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(e) {
if (e.isIntersecting) { e.target.classList.add('visible'); observer.unobserve(e.target); }
});
}, { threshold: 0.1, rootMargin: '0px 0px -30px 0px' });
document.querySelectorAll('.reveal').forEach(function(el) { observer.observe(el); });
}
applyTheme();
applyLang();
})();
</script>
</body>
</html>