TimeLineifyer/index.html
Greg 7497230990 Fix security vulnerabilities found in security review
- Add Content-Security-Policy meta tag restricting external resources
- Add sanitizeEvent/sanitizeTimeline to validate/allowlist data from localStorage and imported JSON
- Escape ev.thumbnail in SVG <image href> with xe() to prevent javascript: URL injection
- Escape dynamic IDs in inline onclick handlers with esc() throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 11:54:52 +01:00

1609 lines
71 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; object-src 'none'; base-uri 'self';">
<title>Timelineifyer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--surface: #ffffff;
--surface2: #f0f0f3;
--border: #d1d1d6;
--text-primary: #1c1c1e;
--text-secondary: #6e6e73;
--accent: #4f46e5;
--accent-hover: #4338ca;
--danger: #dc2626;
--danger-hover: #b91c1c;
--radius: 10px;
--shadow: 0 1px 4px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.12), 0 8px 32px rgba(0,0,0,0.08);
}
[data-theme="dark"] {
--bg: #0f0f11;
--surface: #1c1c1e;
--surface2: #2c2c2e;
--border: #3a3a3c;
--text-primary: #f5f5f7;
--text-secondary: #98989d;
--accent: #6366f1;
--accent-hover: #818cf8;
--danger: #ef4444;
--danger-hover: #f87171;
--shadow: 0 1px 4px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.25);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.4), 0 8px 32px rgba(0,0,0,0.3);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text-primary);
min-height: 100vh;
transition: background 0.2s ease, color 0.2s ease;
font-size: 15px;
line-height: 1.5;
}
/* ── Header ── */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 56px;
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow);
transition: all 0.3s ease;
}
.header-logo {
font-size: 18px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
cursor: pointer;
flex-shrink: 0;
}
.header-title {
font-size: 15px;
color: var(--text-secondary);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions { display: flex; gap: 8px; align-items: center; }
/* ── Presentation mode ── */
[data-presenting] .header { display: none; }
[data-presenting] .view-nav { display: none; }
[data-presenting] .view-shortcuts { display: none; }
[data-presenting] .view-header { padding-top: 20px; }
[data-presenting] .timeline-scroll-wrapper { min-height: calc(100vh - 110px); }
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
text-decoration: none;
background: none;
color: var(--text-primary);
font-family: inherit;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); opacity: 1; }
.btn-ghost { background: transparent; border-color: var(--border); color: var(--text-secondary); }
.btn-ghost:hover { background: var(--surface2); color: var(--text-primary); opacity: 1; }
.btn-active { background: var(--surface2); border-color: var(--accent); color: var(--accent); }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-danger:hover { background: var(--danger-hover); border-color: var(--danger-hover); opacity: 1; }
.btn-icon { padding: 7px; width: 36px; height: 36px; justify-content: center; border-radius: 8px; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* ── Main ── */
.main { max-width: 900px; margin: 0 auto; padding: 32px 24px; }
/* ── Views ── */
.view { display: none; animation: fadeIn 0.2s ease; }
.view.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
/* ── List View ── */
.list-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.list-header h1 { font-size: 22px; font-weight: 700; }
.timelines-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.timeline-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
}
.timeline-card:hover { box-shadow: var(--shadow-lg); transform: translateY(-1px); border-color: var(--accent); }
.timeline-card-name { font-size: 17px; font-weight: 600; margin-bottom: 6px; }
.timeline-card-meta { font-size: 12px; color: var(--text-secondary); margin-bottom: 16px; }
.timeline-card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.new-timeline-card {
background: var(--surface2);
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
font-size: 15px;
transition: all 0.15s ease;
min-height: 120px;
}
.new-timeline-card:hover { border-color: var(--accent); color: var(--accent); background: var(--surface); }
/* ── Edit View ── */
.edit-header { margin-bottom: 28px; }
.edit-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.timeline-name-wrap { display: flex; align-items: center; gap: 8px; }
.timeline-name-display {
font-size: 22px; font-weight: 700; cursor: pointer;
border-bottom: 2px solid transparent; padding-bottom: 2px; transition: border-color 0.15s;
}
.timeline-name-display:hover { border-color: var(--accent); }
.timeline-name-input {
font-size: 22px; font-weight: 700; border: none;
border-bottom: 2px solid var(--accent); background: transparent;
color: var(--text-primary); outline: none; font-family: inherit; min-width: 200px; padding-bottom: 2px;
}
.edit-pencil { color: var(--text-secondary); cursor: pointer; font-size: 14px; opacity: 0.6; transition: opacity 0.15s; }
.edit-pencil:hover { opacity: 1; }
/* Events list */
.events-section { margin-bottom: 32px; }
.events-section h2 { font-size: 15px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; }
.events-empty { text-align: center; padding: 40px; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius); border: 1px dashed var(--border); }
.event-list { display: flex; flex-direction: column; gap: 8px; }
.event-item {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px; display: flex; align-items: flex-start; gap: 12px;
transition: box-shadow 0.15s, border-color 0.15s;
}
.event-item:hover { box-shadow: var(--shadow); border-color: var(--accent); }
.event-thumb-sm {
width: 72px; height: 52px; object-fit: cover; border-radius: 6px;
flex-shrink: 0; border: 1px solid var(--border); cursor: pointer;
}
.event-info { flex: 1; min-width: 0; }
.event-date { font-size: 12px; color: var(--text-secondary); margin-bottom: 2px; }
.event-title { font-weight: 600; font-size: 15px; margin-bottom: 2px; }
.event-desc { font-size: 13px; color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; }
.event-url-badge {
font-size: 11px; color: var(--accent);
background: rgba(79,70,229,0.1); border-radius: 4px;
padding: 1px 6px; margin-top: 4px; display: inline-block;
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.event-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* Inline edit form */
.event-edit-form {
background: var(--surface2); border: 1px solid var(--accent);
border-radius: var(--radius); padding: 16px; margin-top: 4px;
}
.form-row { display: grid; gap: 12px; margin-bottom: 12px; }
.form-row-2 { grid-template-columns: 1fr 1fr; }
.form-row-1 { grid-template-columns: 1fr; }
label { font-size: 12px; font-weight: 600; color: var(--text-secondary); display: block; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
input, textarea, select {
width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px;
background: var(--surface); color: var(--text-primary); font-family: inherit; font-size: 14px;
outline: none; transition: border-color 0.15s;
}
input:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 72px; }
.form-actions { display: flex; gap: 8px; justify-content: flex-end; }
/* Thumbnail upload control */
.thumb-ctrl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
.thumb-preview-sm {
width: 80px; height: 56px; object-fit: cover; border-radius: 6px;
border: 1px solid var(--border); display: block;
}
/* Add event card */
.add-event-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow); }
.add-event-card h3 { font-size: 15px; font-weight: 600; margin-bottom: 16px; }
/* ── View Mode ── */
#view-view { overflow: hidden; }
.view-header { max-width: 900px; margin: 0 auto; padding: 24px 24px 0; }
.view-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.view-title { font-size: 26px; font-weight: 700; margin-bottom: 4px; }
.view-subtitle { font-size: 13px; color: var(--text-secondary); }
.view-shortcuts { font-size: 12px; color: var(--text-secondary); margin-top: 4px; opacity: 0.7; }
.view-shortcuts kbd {
display: inline-block; padding: 1px 5px; border: 1px solid var(--border);
border-radius: 4px; font-size: 11px; font-family: inherit; background: var(--surface2);
}
/* Timeline scroll */
.timeline-scroll-wrapper {
overflow-x: auto; overflow-y: visible;
padding: 60px 80px; min-height: 400px;
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
.timeline-scroll-wrapper::-webkit-scrollbar { height: 6px; }
.timeline-scroll-wrapper::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.timeline-track {
position: relative; display: flex; align-items: center;
min-width: max-content; height: 360px;
}
.timeline-line {
position: absolute; left: 0; right: 0; top: 50%; height: 2px;
background: var(--border); transform: translateY(-50%); z-index: 0;
}
.timeline-events { display: flex; align-items: center; position: relative; z-index: 1; }
.tl-event-wrap {
position: relative; width: 220px; flex-shrink: 0;
display: flex; flex-direction: column; align-items: center;
}
/* Dot */
.tl-dot {
width: 14px; height: 14px; border-radius: 50%; background: var(--accent);
border: 3px solid var(--surface); box-shadow: 0 0 0 2px var(--accent);
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%); z-index: 2;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.tl-event-wrap:hover .tl-dot { transform: translate(-50%, -50%) scale(1.3); }
/* Focused event */
.tl-event-wrap.tl-focused .tl-dot {
transform: translate(-50%, -50%) scale(1.45);
box-shadow: 0 0 0 5px rgba(79,70,229,0.25), 0 0 0 2px var(--accent);
}
.tl-event-wrap.tl-focused .tl-card-above,
.tl-event-wrap.tl-focused .tl-card-below {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(79,70,229,0.2), var(--shadow-lg);
}
/* Cards */
.tl-card-above, .tl-card-below {
width: 190px; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow);
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
text-decoration: none; color: inherit; display: block; overflow: hidden;
}
.tl-card-above:hover, .tl-card-below:hover { box-shadow: var(--shadow-lg); border-color: var(--accent); transform: translateY(-2px); }
a.tl-card-above, a.tl-card-below { cursor: pointer; }
.tl-card-thumb {
display: block; width: calc(100% + 28px);
margin: -12px -14px 10px -14px; height: 72px;
object-fit: cover;
}
a.tl-card-above .tl-card-thumb,
a.tl-card-below .tl-card-thumb { cursor: pointer; }
.tl-card-date { font-size: 11px; color: var(--text-secondary); margin-bottom: 3px; }
.tl-card-title { font-size: 13px; font-weight: 600; line-height: 1.3; }
.tl-card-desc { font-size: 11px; color: var(--text-secondary); margin-top: 4px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
.tl-card-link-hint { font-size: 10px; color: var(--accent); margin-top: 4px; }
/* Connectors */
.tl-connector-above, .tl-connector-below { width: 2px; border-left: 2px dashed var(--border); }
.tl-above-group {
display: flex; flex-direction: column; align-items: center;
position: absolute; bottom: calc(50% + 7px); left: 50%; transform: translateX(-50%);
}
.tl-above-group .tl-connector-above { height: 30px; }
.tl-below-group {
display: flex; flex-direction: column; align-items: center;
position: absolute; top: calc(50% + 7px); left: 50%; transform: translateX(-50%);
}
.tl-below-group .tl-connector-below { height: 30px; }
/* ── Modals ── */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 200; align-items: center; justify-content: center; backdrop-filter: blur(2px);
}
.modal-overlay.open { display: flex; animation: fadeIn 0.15s ease; }
.modal {
background: var(--surface); border-radius: var(--radius); padding: 28px;
max-width: 400px; width: calc(100% - 48px); box-shadow: var(--shadow-lg); border: 1px solid var(--border);
}
.modal h2 { font-size: 18px; font-weight: 700; margin-bottom: 8px; }
.modal p { color: var(--text-secondary); font-size: 14px; margin-bottom: 20px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
/* ── Toast ── */
.toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 300; display: flex; flex-direction: column; gap: 8px; }
.toast {
background: var(--text-primary); color: var(--bg); padding: 10px 18px;
border-radius: 8px; font-size: 13px; font-weight: 500; box-shadow: var(--shadow-lg);
animation: slideIn 0.2s ease, fadeOut 0.3s ease 2.5s forwards; max-width: 320px;
}
@keyframes slideIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes fadeOut { to { opacity: 0; transform: translateY(4px); } }
/* ── Misc ── */
.empty-state { text-align: center; padding: 80px 24px; color: var(--text-secondary); }
.empty-state h2 { font-size: 20px; color: var(--text-primary); margin-bottom: 8px; }
@media (max-width: 600px) {
.form-row-2 { grid-template-columns: 1fr; }
.timeline-card-actions { flex-direction: column; }
.list-header { flex-direction: column; align-items: flex-start; gap: 12px; }
}
/* ── Zoom controls ── */
.zoom-group { display: flex; align-items: center; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.zoom-group .btn { border: none; border-radius: 0; padding: 7px 10px; min-width: 32px; }
.zoom-label { font-size: 12px; font-weight: 600; min-width: 42px; text-align: center; color: var(--text-secondary); padding: 0 2px; }
/* ── Category chips (edit view) ── */
.cat-section { margin-bottom: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
.cat-section-header { display: flex; align-items: center; justify-content: space-between; cursor: pointer; user-select: none; }
.cat-section-header h2 { font-size: 15px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
.cat-body { margin-top: 12px; }
.cat-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; min-height: 0; }
.cat-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; color: #fff; }
.cat-chip-del { background: none; border: none; cursor: pointer; color: inherit; opacity: 0.75; padding: 0 0 0 2px; line-height: 1; font-size: 15px; font-weight: 400; }
.cat-chip-del:hover { opacity: 1; }
.cat-add-form { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cat-add-form input { flex: 1; min-width: 120px; max-width: 200px; }
.cat-swatches { display: flex; gap: 5px; flex-wrap: wrap; }
.cat-swatch { width: 22px; height: 22px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: transform 0.15s, border-color 0.15s; flex-shrink: 0; }
.cat-swatch:hover { transform: scale(1.2); }
.cat-swatch.selected { border-color: var(--text-primary); box-shadow: 0 0 0 1px var(--surface); }
/* ── Today indicator ── */
.tl-today { position: absolute; top: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; z-index: 3; pointer-events: none; transform: translateX(-50%); }
.tl-today-pill { background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 10px; white-space: nowrap; flex-shrink: 0; }
.tl-today-line { flex: 1; width: 2px; margin-top: 3px; background: repeating-linear-gradient(to bottom, var(--accent) 0px, var(--accent) 5px, transparent 5px, transparent 9px); opacity: 0.6; }
/* ── Legend ── */
.tl-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; }
.tl-legend-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-secondary); }
.tl-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
/* ── Duration bar ── */
.tl-duration-bar { position: absolute; top: 50%; height: 6px; background: var(--accent); border-radius: 0 3px 3px 0; transform: translateY(-50%); z-index: 1; opacity: 0.65; }
.tl-duration-cap { position: absolute; top: 50%; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); transform: translate(-50%, -50%); z-index: 2; }
/* ── Slide transitions ── */
@keyframes slideInRight { from { opacity:0; transform:translateX(30px) } to { opacity:1; transform:translateX(0) } }
@keyframes slideInLeft { from { opacity:0; transform:translateX(-30px)} to { opacity:1; transform:translateX(0) } }
.tl-event-wrap.slide-right .tl-card-above,
.tl-event-wrap.slide-right .tl-card-below { animation: slideInRight 220ms ease; }
.tl-event-wrap.slide-left .tl-card-above,
.tl-event-wrap.slide-left .tl-card-below { animation: slideInLeft 220ms ease; }
</style>
</head>
<body>
<header class="header">
<div class="header-logo" onclick="navTo('list')">Timelineifyer</div>
<div class="header-title" id="header-title"></div>
<div class="header-actions">
<button class="btn btn-ghost" onclick="triggerImport()" title="Import JSON">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12M8 2v9M4 7l4 4 4-4"/></svg>
Import
</button>
<button class="btn btn-icon btn-ghost" onclick="toggleTheme()" title="Toggle theme">
<span id="theme-icon">🌙</span>
</button>
</div>
</header>
<!-- List View -->
<div id="list-view" class="view">
<div class="main">
<div class="list-header"><h1>Your Timelines</h1></div>
<div class="timelines-grid" id="timelines-grid"></div>
</div>
</div>
<!-- Edit View -->
<div id="edit-view" class="view">
<div class="main">
<div class="edit-header">
<div class="edit-nav">
<button class="btn btn-ghost" onclick="navTo('list')">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3L5 8l5 5"/></svg>
Back
</button>
<button class="btn btn-primary" onclick="navTo('view')">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3"/><path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5-7-5-7-5z"/></svg>
View Timeline
</button>
<button class="btn btn-ghost" id="edit-datefmt-btn" onclick="toggleDateFormat()" title="Toggle date format">
Mon YYYY
</button>
</div>
<div class="timeline-name-wrap">
<span class="timeline-name-display" id="edit-name-display" onclick="startEditName()" title="Click to rename"></span>
<input class="timeline-name-input" id="edit-name-input" style="display:none" onblur="finishEditName()" onkeydown="nameKeydown(event)" />
<span class="edit-pencil" onclick="startEditName()" title="Rename">✏️</span>
</div>
</div>
<div id="cat-section"></div>
<div class="events-section">
<h2>Events</h2>
<div id="event-list"></div>
</div>
<div class="add-event-card">
<h3>Add Event</h3>
<div class="form-row form-row-2">
<div>
<label for="add-date">Date *</label>
<input type="date" id="add-date">
</div>
<div>
<label for="add-end-date">End Date (optional)</label>
<input type="date" id="add-end-date">
</div>
</div>
<div class="form-row form-row-2">
<div>
<label for="add-title">Title *</label>
<input type="text" id="add-title" placeholder="Event title">
</div>
<div>
<label for="add-category">Category</label>
<select id="add-category"><option value="">— none —</option></select>
</div>
</div>
<div class="form-row form-row-1">
<div>
<label for="add-desc">Description</label>
<textarea id="add-desc" placeholder="Optional description..."></textarea>
</div>
</div>
<div class="form-row form-row-2">
<div>
<label for="add-url">URL</label>
<input type="url" id="add-url" placeholder="https://...">
</div>
<div>
<label>Thumbnail</label>
<div class="thumb-ctrl">
<button type="button" class="btn btn-ghost btn-sm" onclick="triggerThumbUpload('add')">Upload image</button>
<img id="add-thumb-preview" class="thumb-preview-sm" style="display:none" alt="">
<button type="button" class="btn btn-ghost btn-sm" id="add-thumb-clear" style="display:none" onclick="clearAddThumb()">Remove</button>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="addEvent()">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 2v12M2 8h12"/></svg>
Add Event
</button>
</div>
</div>
</div>
</div>
<!-- View Mode -->
<div id="view-view" class="view">
<div class="view-header">
<div class="view-nav">
<button class="btn btn-ghost" onclick="navTo('list')">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3L5 8l5 5"/></svg>
Back
</button>
<button class="btn btn-ghost" onclick="navTo('edit')">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2l3 3-8 8H3v-3L11 2z"/></svg>
Edit
</button>
<button class="btn btn-ghost" id="view-datefmt-btn" onclick="toggleDateFormat()" title="Toggle date format">
Mon YYYY
</button>
<div class="zoom-group">
<button class="btn btn-ghost" onclick="adjustZoom(-1)" title="Zoom out"></button>
<span class="zoom-label" id="zoom-label">100%</span>
<button class="btn btn-ghost" onclick="adjustZoom(1)" title="Zoom in">+</button>
</div>
<button class="btn btn-ghost" onclick="exportSVG()" title="Export as SVG for PowerPoint">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="12" height="12" rx="2"/><path d="M5 8h6M8 5v6"/></svg>
Export SVG
</button>
<button class="btn btn-ghost" onclick="exportPNG()" title="Export as PNG image">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="12" height="12" rx="2"/><path d="M2 11l3-3 2 2 3-4 4 5"/></svg>
Export PNG
</button>
<button class="btn btn-ghost" onclick="enterPresentation()" title="Presentation mode (P)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="2" width="14" height="10" rx="1"/><path d="M5 14h6M8 12v2"/></svg>
Present
</button>
</div>
<div class="view-title" id="view-title"></div>
<div class="view-subtitle" id="view-subtitle"></div>
<div id="tl-legend-wrap"></div>
<div class="view-shortcuts" id="view-shortcuts">
<kbd></kbd> <kbd></kbd> navigate events &nbsp;·&nbsp; <kbd>P</kbd> present
</div>
</div>
<div class="timeline-scroll-wrapper" id="timeline-scroll-wrapper">
<div class="timeline-track" id="timeline-track">
<div class="timeline-line"></div>
<div class="timeline-events" id="timeline-events"></div>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div class="modal-overlay" id="confirm-modal">
<div class="modal">
<h2 id="confirm-title">Confirm</h2>
<p id="confirm-message"></p>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger" id="confirm-ok-btn">Delete</button>
</div>
</div>
</div>
<!-- New Timeline Modal -->
<div class="modal-overlay" id="new-timeline-modal">
<div class="modal">
<h2>New Timeline</h2>
<p>Give your timeline a name to get started.</p>
<div style="margin-bottom: 20px">
<label for="new-timeline-name">Timeline Name</label>
<input type="text" id="new-timeline-name" placeholder="e.g. Project Roadmap, Life Events..." onkeydown="if(event.key==='Enter') createTimeline()">
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeNewTimelineModal()">Cancel</button>
<button class="btn btn-primary" onclick="createTimeline()">Create</button>
</div>
</div>
</div>
<!-- Hidden file inputs -->
<input type="file" id="import-file" accept=".json" style="display:none" onchange="importJSON(event)">
<input type="file" id="thumb-file-input" accept="image/*" style="display:none" onchange="onThumbFileChange(event)">
<div class="toast-container" id="toast-container"></div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
let state = { timelines: [], currentTimelineId: null, view: 'list' };
let editingEventId = null;
let confirmCallback = null;
let focusedEventIndex = 0;
let presentationMode = false;
let pendingThumb = null; // base64 for add-event form
let editThumb = undefined; // undefined=no change | null=cleared | string=new data URL
let thumbContext = 'add'; // which form triggered the file picker
let zoomLevel = 1.0;
let catSectionOpen = true;
let selectedCatColor = '#4f46e5';
const PRESET_COLORS = ['#4f46e5','#e11d48','#d97706','#059669','#0284c7','#7c3aed','#ea580c','#0d9488'];
// ── Sanitization ───────────────────────────────────────────────────────────
function sanitizeEvent(ev) {
if (!ev || typeof ev !== 'object') return null;
if (typeof ev.id !== 'string' || !ev.id) return null;
if (typeof ev.date !== 'string' || !ev.date) return null;
if (typeof ev.title !== 'string' || !ev.title) return null;
const thumb = typeof ev.thumbnail === 'string' && ev.thumbnail.startsWith('data:image/') ? ev.thumbnail : '';
return {
id: ev.id,
title: ev.title,
date: ev.date,
endDate: typeof ev.endDate === 'string' ? ev.endDate : undefined,
description: typeof ev.description === 'string' ? ev.description : '',
url: typeof ev.url === 'string' ? ev.url : '',
thumbnail: thumb,
category: typeof ev.category === 'string' ? ev.category : undefined,
};
}
function sanitizeTimeline(tl) {
if (!tl || typeof tl !== 'object') return null;
if (typeof tl.id !== 'string' || !tl.id) return null;
if (typeof tl.name !== 'string' || !tl.name) return null;
if (!Array.isArray(tl.events)) return null;
return {
id: tl.id,
name: tl.name,
createdAt: typeof tl.createdAt === 'string' ? tl.createdAt : '',
dateFormat: tl.dateFormat === 'month-year' ? 'month-year' : 'full',
categories: Array.isArray(tl.categories) ? tl.categories.filter(c =>
c && typeof c === 'object' &&
typeof c.id === 'string' && c.id &&
typeof c.name === 'string' && c.name &&
typeof c.color === 'string'
).map(c => ({ id: c.id, name: c.name, color: c.color })) : [],
events: tl.events.map(sanitizeEvent).filter(Boolean),
};
}
// ── Storage ────────────────────────────────────────────────────────────────
function saveState() {
try {
localStorage.setItem('timelineifyer', JSON.stringify({ timelines: state.timelines }));
} catch(e) {
toast('Storage full — export your data to free space.');
}
}
function loadState() {
try {
const raw = localStorage.getItem('timelineifyer');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.timelines && Array.isArray(parsed.timelines)) {
state.timelines = parsed.timelines.map(sanitizeTimeline).filter(Boolean);
}
}
} catch(e) { console.warn('Failed to load state', e); }
}
// ── UUID ───────────────────────────────────────────────────────────────────
function uuid() {
return crypto.randomUUID ? crypto.randomUUID() :
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
// ── Theme ──────────────────────────────────────────────────────────────────
function initTheme() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(saved || (prefersDark ? 'dark' : 'light'));
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
document.getElementById('theme-icon').textContent = theme === 'dark' ? '☀️' : '🌙';
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
applyTheme(next);
localStorage.setItem('theme', next);
}
// ── Navigation ─────────────────────────────────────────────────────────────
function navTo(view) {
state.view = view;
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(view + '-view').classList.add('active');
const tl = getCurrentTimeline();
document.getElementById('header-title').textContent = tl && view !== 'list' ? tl.name : '';
if (view === 'list') renderList();
if (view === 'edit') renderEdit();
if (view === 'view') { focusedEventIndex = 0; renderViewMode(); }
}
// ── Helpers ────────────────────────────────────────────────────────────────
function getCurrentTimeline() {
return state.timelines.find(t => t.id === state.currentTimelineId) || null;
}
function sortedEvents(tl) {
return [...tl.events].sort((a, b) => a.date.localeCompare(b.date));
}
function formatDate(dateStr, fmt) {
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-').map(Number);
if (fmt === 'month-year') {
return new Date(y, m - 1, d).toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
}
return new Date(y, m - 1, d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
function dateRange(tl) {
if (!tl.events.length) return 'No events yet';
const fmt = tl.dateFormat || 'full';
const sorted = sortedEvents(tl);
const first = formatDate(sorted[0].date, fmt);
const lastEv = sorted[sorted.length - 1];
const last = formatDate(lastEv.endDate || lastEv.date, fmt);
return first === last ? first : `${first}${last}`;
}
function currentFmt() {
return (getCurrentTimeline()?.dateFormat) || 'full';
}
// ── Date format toggle ─────────────────────────────────────────────────────
function toggleDateFormat() {
const tl = getCurrentTimeline();
if (!tl) return;
tl.dateFormat = tl.dateFormat === 'month-year' ? 'full' : 'month-year';
saveState();
updateDateFmtButtons();
if (state.view === 'edit') renderEventList();
if (state.view === 'view') renderViewMode();
}
function updateDateFmtButtons() {
const tl = getCurrentTimeline();
const active = tl?.dateFormat === 'month-year';
['view-datefmt-btn', 'edit-datefmt-btn'].forEach(id => {
const btn = document.getElementById(id);
if (!btn) return;
btn.className = 'btn ' + (active ? 'btn-active' : 'btn-ghost');
btn.title = active ? 'Showing Month + Year only (click for full dates)' : 'Toggle to Month + Year only';
});
}
// ── Zoom ───────────────────────────────────────────────────────────────────
function adjustZoom(dir) {
zoomLevel = Math.min(2, Math.max(0.5, zoomLevel + dir * 0.25));
renderViewMode();
}
// ── Categories ─────────────────────────────────────────────────────────────
function renderCatSection() {
const tl = getCurrentTimeline();
if (!tl) return;
const cats = tl.categories || [];
const catListHtml = cats.length
? cats.map(c => `<div class="cat-chip" style="background:${c.color}">${esc(c.name)}<button class="cat-chip-del" onclick="deleteCategory('${esc(c.id)}')" title="Delete">×</button></div>`).join('')
: '<span style="font-size:13px;color:var(--text-secondary)">No categories yet.</span>';
const swatchHtml = PRESET_COLORS.map(color =>
`<div class="cat-swatch${selectedCatColor === color ? ' selected' : ''}" style="background:${color}" onclick="selectCatColor('${esc(color)}')" title="${color}"></div>`
).join('');
const el = document.getElementById('cat-section');
if (!el) return;
el.innerHTML = `
<div class="cat-section">
<div class="cat-section-header" onclick="toggleCatSection()">
<h2>Categories${cats.length ? ` <span style="font-weight:400;font-size:12px">(${cats.length})</span>` : ''}</h2>
<button class="btn btn-ghost btn-sm" type="button">${catSectionOpen ? '▴' : '▾'}</button>
</div>
<div class="cat-body" id="cat-body" style="display:${catSectionOpen ? '' : 'none'}">
<div class="cat-list">${catListHtml}</div>
<div class="cat-add-form">
<input type="text" id="cat-name-input" placeholder="Category name" onkeydown="if(event.key==='Enter')addCategory()">
<div class="cat-swatches">${swatchHtml}</div>
<button class="btn btn-primary btn-sm" type="button" onclick="addCategory()">Add</button>
</div>
</div>
</div>`;
populateCategorySelects();
}
function toggleCatSection() {
catSectionOpen = !catSectionOpen;
const body = document.getElementById('cat-body');
if (body) body.style.display = catSectionOpen ? '' : 'none';
renderCatSection();
}
function selectCatColor(color) {
selectedCatColor = color;
renderCatSection();
document.getElementById('cat-name-input')?.focus();
}
function addCategory() {
const tl = getCurrentTimeline();
if (!tl) return;
const nameEl = document.getElementById('cat-name-input');
const name = nameEl?.value.trim();
if (!name) { nameEl?.focus(); return; }
if (!tl.categories) tl.categories = [];
tl.categories.push({ id: uuid(), name, color: selectedCatColor });
saveState();
renderCatSection();
}
function deleteCategory(catId) {
const tl = getCurrentTimeline();
if (!tl) return;
tl.categories = (tl.categories || []).filter(c => c.id !== catId);
tl.events.forEach(ev => { if (ev.category === catId) ev.category = undefined; });
saveState();
renderCatSection();
}
function populateCategorySelects() {
const tl = getCurrentTimeline();
const cats = tl?.categories || [];
const opts = '<option value="">— none —</option>' +
cats.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
['add-category', 'ie-category'].forEach(id => {
const sel = document.getElementById(id);
if (sel) { const prev = sel.value; sel.innerHTML = opts; sel.value = prev; }
});
}
// ── Toast ──────────────────────────────────────────────────────────────────
function toast(msg) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), 3100);
}
// ── Confirm Modal ──────────────────────────────────────────────────────────
function showConfirm(title, message, onOk) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-message').textContent = message;
document.getElementById('confirm-ok-btn').onclick = () => { closeModal(); onOk(); };
document.getElementById('confirm-modal').classList.add('open');
}
function closeModal() {
document.getElementById('confirm-modal').classList.remove('open');
}
document.getElementById('confirm-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
document.getElementById('new-timeline-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeNewTimelineModal(); });
// ── List View ──────────────────────────────────────────────────────────────
function renderList() {
const grid = document.getElementById('timelines-grid');
grid.innerHTML = '';
const newCard = document.createElement('div');
newCard.className = 'new-timeline-card';
newCard.innerHTML = '<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 2v12M2 8h12"/></svg> New Timeline';
newCard.onclick = openNewTimelineModal;
grid.appendChild(newCard);
state.timelines.forEach(tl => {
const card = document.createElement('div');
card.className = 'timeline-card';
const count = tl.events.length;
card.innerHTML = `
<div class="timeline-card-name">${esc(tl.name)}</div>
<div class="timeline-card-meta">${count} event${count !== 1 ? 's' : ''} &nbsp;·&nbsp; ${esc(dateRange(tl))}</div>
<div class="timeline-card-actions">
<button class="btn btn-primary btn-sm" onclick="openTimeline('${esc(tl.id)}','view')">View</button>
<button class="btn btn-ghost btn-sm" onclick="openTimeline('${esc(tl.id)}','edit')">Edit</button>
<button class="btn btn-ghost btn-sm" onclick="exportTimeline('${esc(tl.id)}')">Export</button>
<button class="btn btn-danger btn-sm" onclick="deleteTimeline('${esc(tl.id)}')">Delete</button>
</div>`;
grid.appendChild(card);
});
if (!state.timelines.length) {
const empty = document.createElement('div');
empty.style.gridColumn = '1/-1';
empty.innerHTML = '<div class="empty-state"><h2>No timelines yet</h2><p>Create your first timeline to get started.</p></div>';
grid.appendChild(empty);
}
}
function openTimeline(id, mode) {
if (state.currentTimelineId !== id) zoomLevel = 1.0;
state.currentTimelineId = id;
navTo(mode);
}
// ── New Timeline ───────────────────────────────────────────────────────────
function openNewTimelineModal() {
document.getElementById('new-timeline-name').value = '';
document.getElementById('new-timeline-modal').classList.add('open');
setTimeout(() => document.getElementById('new-timeline-name').focus(), 50);
}
function closeNewTimelineModal() {
document.getElementById('new-timeline-modal').classList.remove('open');
}
function createTimeline() {
const name = document.getElementById('new-timeline-name').value.trim();
if (!name) { document.getElementById('new-timeline-name').focus(); return; }
const tl = { id: uuid(), name, createdAt: new Date().toISOString().slice(0,10), dateFormat: 'full', categories: [], events: [] };
state.timelines.unshift(tl);
state.currentTimelineId = tl.id;
saveState();
closeNewTimelineModal();
navTo('edit');
}
// ── Delete Timeline ────────────────────────────────────────────────────────
function deleteTimeline(id) {
const tl = state.timelines.find(t => t.id === id);
showConfirm('Delete Timeline', `Delete "${tl.name}" and all its events? This cannot be undone.`, () => {
state.timelines = state.timelines.filter(t => t.id !== id);
if (state.currentTimelineId === id) state.currentTimelineId = null;
saveState(); renderList(); toast('Timeline deleted.');
});
}
// ── Export / Import ────────────────────────────────────────────────────────
function exportTimeline(id) {
const tl = state.timelines.find(t => t.id === id);
download(`timeline-${tl.name.replace(/[^a-z0-9]/gi,'_').toLowerCase()}.json`,
JSON.stringify({ timelines: [tl] }, null, 2), 'application/json');
toast('Exported!');
}
function triggerImport() { document.getElementById('import-file').click(); }
function importJSON(e) {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
try {
const data = JSON.parse(ev.target.result);
if (!data.timelines || !Array.isArray(data.timelines)) throw new Error('Invalid format');
let added = 0;
data.timelines.forEach(raw => {
const tl = sanitizeTimeline(raw);
if (!tl) return;
const idx = state.timelines.findIndex(t => t.id === tl.id);
if (idx >= 0) { state.timelines[idx] = tl; } else { state.timelines.push(tl); }
added++;
});
saveState(); navTo('list');
toast(`Imported ${added} timeline${added !== 1 ? 's' : ''}.`);
} catch { toast('Import failed: invalid JSON file.'); }
e.target.value = '';
};
reader.readAsText(file);
}
function download(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
// ── Thumbnail helpers ──────────────────────────────────────────────────────
function resizeImageToDataURL(file, maxW, maxH, quality) {
return new Promise(resolve => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > maxW || h > maxH) {
const r = Math.min(maxW / w, maxH / h);
w = Math.round(w * r); h = Math.round(h * r);
}
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(c.toDataURL('image/jpeg', quality));
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
function triggerThumbUpload(ctx) {
thumbContext = ctx;
document.getElementById('thumb-file-input').click();
}
async function onThumbFileChange(e) {
const file = e.target.files[0]; if (!file) return;
const dataUrl = await resizeImageToDataURL(file, 400, 300, 0.82);
if (!dataUrl) { toast('Could not read image.'); return; }
if (thumbContext === 'add') {
pendingThumb = dataUrl;
const prev = document.getElementById('add-thumb-preview');
const clr = document.getElementById('add-thumb-clear');
if (prev) { prev.src = dataUrl; prev.style.display = ''; }
if (clr) clr.style.display = '';
} else {
editThumb = dataUrl;
const prev = document.getElementById('ie-thumb-preview');
const clr = document.getElementById('ie-thumb-clear');
if (prev) { prev.src = dataUrl; prev.style.display = ''; }
if (clr) { clr.style.display = ''; clr.textContent = 'Remove'; }
// Update upload btn text
const uploadBtn = document.getElementById('ie-thumb-upload-btn');
if (uploadBtn) uploadBtn.textContent = 'Replace image';
}
e.target.value = '';
}
function clearAddThumb() {
pendingThumb = null;
const prev = document.getElementById('add-thumb-preview');
const clr = document.getElementById('add-thumb-clear');
if (prev) { prev.src = ''; prev.style.display = 'none'; }
if (clr) clr.style.display = 'none';
}
function clearEditThumb() {
editThumb = null;
const prev = document.getElementById('ie-thumb-preview');
const clr = document.getElementById('ie-thumb-clear');
if (prev) { prev.src = ''; prev.style.display = 'none'; }
if (clr) clr.style.display = 'none';
const uploadBtn = document.getElementById('ie-thumb-upload-btn');
if (uploadBtn) uploadBtn.textContent = 'Upload image';
}
function thumbClick(el) {
const url = el.dataset.url;
if (url) window.open(url, '_blank', 'noopener,noreferrer');
}
// ── Edit View ──────────────────────────────────────────────────────────────
function renderEdit() {
editingEventId = null; editThumb = undefined;
const tl = getCurrentTimeline();
if (!tl) { navTo('list'); return; }
document.getElementById('edit-name-display').textContent = tl.name;
document.getElementById('header-title').textContent = tl.name;
updateDateFmtButtons();
renderCatSection();
renderEventList();
['add-date','add-end-date','add-title','add-desc','add-url'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
const catSel = document.getElementById('add-category');
if (catSel) catSel.value = '';
clearAddThumb();
}
function renderEventList() {
const tl = getCurrentTimeline();
const container = document.getElementById('event-list');
const events = sortedEvents(tl);
if (!events.length) {
container.innerHTML = '<div class="events-empty">No events yet. Add your first event below.</div>';
return;
}
container.innerHTML = '';
events.forEach(ev => {
const wrap = document.createElement('div');
wrap.id = `ev-wrap-${ev.id}`;
wrap.innerHTML = editingEventId === ev.id ? buildInlineEditForm(ev) : buildEventRow(ev);
container.appendChild(wrap);
});
}
function buildEventRow(ev) {
const fmt = currentFmt();
const tl = getCurrentTimeline();
const cat = (tl.categories || []).find(c => c.id === ev.category);
const catDot = cat ? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${cat.color};margin-right:4px;vertical-align:middle"></span>` : '';
const dateStr = ev.endDate
? `${formatDate(ev.date, fmt)} ${formatDate(ev.endDate, fmt)}`
: formatDate(ev.date, fmt);
const thumb = ev.thumbnail
? `<img class="event-thumb-sm" src="${ev.thumbnail}" alt="thumbnail"
data-url="${esc(ev.url||'')}" onclick="thumbClick(this)"
title="${ev.url ? 'Click to open link' : ''}">`
: '';
const desc = ev.description ? `<div class="event-desc">${esc(ev.description)}</div>` : '';
const urlBadge = ev.url ? `<div class="event-url-badge" title="${esc(ev.url)}">↗ ${esc(ev.url)}</div>` : '';
return `
<div class="event-item">
${thumb}
<div class="event-info">
<div class="event-date">${catDot}${esc(dateStr)}</div>
<div class="event-title">${esc(ev.title)}</div>
${desc}${urlBadge}
</div>
<div class="event-actions">
<button class="btn btn-ghost btn-sm" onclick="startEditEvent('${esc(ev.id)}')">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteEvent('${esc(ev.id)}')">Delete</button>
</div>
</div>`;
}
function buildInlineEditForm(ev) {
const hasThumb = !!ev.thumbnail;
return `
<div class="event-edit-form">
<div class="form-row form-row-2">
<div>
<label>Date *</label>
<input type="date" id="ie-date" value="${esc(ev.date)}">
</div>
<div>
<label>End Date (optional)</label>
<input type="date" id="ie-end-date" value="${esc(ev.endDate||'')}">
</div>
</div>
<div class="form-row form-row-2">
<div>
<label>Title *</label>
<input type="text" id="ie-title" value="${esc(ev.title)}" placeholder="Event title">
</div>
<div>
<label>Category</label>
<select id="ie-category"><option value="">— none —</option></select>
</div>
</div>
<div class="form-row form-row-1">
<div>
<label>Description</label>
<textarea id="ie-desc" placeholder="Optional description...">${esc(ev.description||'')}</textarea>
</div>
</div>
<div class="form-row form-row-2">
<div>
<label>URL</label>
<input type="url" id="ie-url" value="${esc(ev.url||'')}" placeholder="https://...">
</div>
<div>
<label>Thumbnail</label>
<div class="thumb-ctrl">
<button type="button" class="btn btn-ghost btn-sm" id="ie-thumb-upload-btn"
onclick="triggerThumbUpload('edit')">${hasThumb ? 'Replace image' : 'Upload image'}</button>
<img id="ie-thumb-preview" class="thumb-preview-sm" alt=""
src="${hasThumb ? ev.thumbnail : ''}"
style="${hasThumb ? '' : 'display:none'}">
<button type="button" class="btn btn-ghost btn-sm" id="ie-thumb-clear"
style="${hasThumb ? '' : 'display:none'}" onclick="clearEditThumb()">Remove</button>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn btn-ghost btn-sm" onclick="cancelEditEvent()">Cancel</button>
<button class="btn btn-primary btn-sm" onclick="saveEditEvent('${esc(ev.id)}')">Save</button>
</div>
</div>`;
}
function startEditEvent(id) {
editingEventId = id; editThumb = undefined;
renderEventList();
const tl = getCurrentTimeline();
const ev = tl.events.find(e => e.id === id);
populateCategorySelects();
const sel = document.getElementById('ie-category');
if (sel && ev?.category) sel.value = ev.category;
document.getElementById('ie-title')?.focus();
}
function cancelEditEvent() { editingEventId = null; editThumb = undefined; renderEventList(); }
function saveEditEvent(id) {
const tl = getCurrentTimeline();
const ev = tl.events.find(e => e.id === id);
const date = document.getElementById('ie-date').value;
const endDate = document.getElementById('ie-end-date').value;
const title = document.getElementById('ie-title').value.trim();
if (!date || !title) { toast('Date and title are required.'); return; }
if (endDate && endDate < date) { toast('End date must be on or after start date.'); return; }
ev.date = date; ev.title = title;
ev.endDate = endDate || undefined;
ev.category = document.getElementById('ie-category').value || undefined;
ev.description = document.getElementById('ie-desc').value.trim();
ev.url = document.getElementById('ie-url').value.trim();
if (editThumb !== undefined) ev.thumbnail = editThumb || '';
editingEventId = null; editThumb = undefined;
saveState(); renderEventList(); toast('Event updated.');
}
function addEvent() {
const tl = getCurrentTimeline();
const date = document.getElementById('add-date').value;
const endDate = document.getElementById('add-end-date').value;
const title = document.getElementById('add-title').value.trim();
if (!date || !title) { toast('Date and title are required.'); return; }
if (endDate && endDate < date) { toast('End date must be on or after start date.'); return; }
const category = document.getElementById('add-category').value || undefined;
tl.events.push({
id: uuid(), title, date,
endDate: endDate || undefined,
category,
description: document.getElementById('add-desc').value.trim(),
url: document.getElementById('add-url').value.trim(),
thumbnail: pendingThumb || ''
});
saveState();
['add-date','add-end-date','add-title','add-desc','add-url'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
const catSel = document.getElementById('add-category');
if (catSel) catSel.value = '';
clearAddThumb();
renderEventList();
document.getElementById('add-title').focus();
toast('Event added.');
}
function deleteEvent(id) {
showConfirm('Delete Event', 'Delete this event? This cannot be undone.', () => {
const tl = getCurrentTimeline();
tl.events = tl.events.filter(e => e.id !== id);
saveState(); renderEventList(); toast('Event deleted.');
});
}
// ── Inline name edit ───────────────────────────────────────────────────────
function startEditName() {
const tl = getCurrentTimeline();
document.getElementById('edit-name-display').style.display = 'none';
const inp = document.getElementById('edit-name-input');
inp.value = tl.name; inp.style.display = ''; inp.focus(); inp.select();
}
function finishEditName() {
const tl = getCurrentTimeline();
const name = document.getElementById('edit-name-input').value.trim();
if (name) { tl.name = name; saveState(); }
document.getElementById('edit-name-input').style.display = 'none';
document.getElementById('edit-name-display').textContent = tl.name;
document.getElementById('edit-name-display').style.display = '';
document.getElementById('header-title').textContent = tl.name;
}
function nameKeydown(e) {
if (e.key === 'Enter') document.getElementById('edit-name-input').blur();
if (e.key === 'Escape') { document.getElementById('edit-name-input').value = getCurrentTimeline().name; document.getElementById('edit-name-input').blur(); }
}
// ── View Mode ──────────────────────────────────────────────────────────────
function renderViewMode() {
const tl = getCurrentTimeline();
if (!tl) { navTo('list'); return; }
document.getElementById('view-title').textContent = tl.name;
document.getElementById('header-title').textContent = tl.name;
updateDateFmtButtons();
// Zoom label
const zoomLabelEl = document.getElementById('zoom-label');
if (zoomLabelEl) zoomLabelEl.textContent = Math.round(zoomLevel * 100) + '%';
const events = sortedEvents(tl);
const fmt = tl.dateFormat || 'full';
document.getElementById('view-subtitle').textContent =
events.length
? `${events.length} event${events.length !== 1 ? 's' : ''} · ${dateRange(tl)}`
: 'No events yet';
// Legend
const legendWrap = document.getElementById('tl-legend-wrap');
if (legendWrap) {
const cats = tl.categories || [];
const usedCats = cats.filter(c => events.some(e => e.category === c.id));
legendWrap.innerHTML = usedCats.length
? `<div class="tl-legend">${usedCats.map(c =>
`<div class="tl-legend-item"><div class="tl-legend-dot" style="background:${c.color}"></div><span>${esc(c.name)}</span></div>`
).join('')}</div>`
: '';
}
const wrapper = document.getElementById('timeline-scroll-wrapper');
if (!events.length) {
wrapper.innerHTML = `<div class="empty-state" style="padding:80px 24px">
<h2>No events</h2><p>Add events in Edit mode to see your timeline.</p></div>`;
return;
}
wrapper.innerHTML = `
<div class="timeline-track" id="timeline-track">
<div class="timeline-line"></div>
<div class="timeline-events" id="timeline-events"></div>
</div>`;
const spacing = Math.round(220 * zoomLevel);
const eventsEl = document.getElementById('timeline-events');
// Date math for duration bars and today indicator
const firstDateMs = new Date(events[0].date).getTime();
const lastDateMs = new Date(events[events.length - 1].date).getTime();
const totalMs = lastDateMs - firstDateMs;
const totalWidth = events.length > 1 ? (events.length - 1) * spacing : spacing;
events.forEach((ev, i) => {
const above = i % 2 === 0;
const wrap = document.createElement('div');
wrap.className = 'tl-event-wrap';
wrap.style.width = spacing + 'px';
// Category dot color
const cats = tl.categories || [];
const cat = cats.find(c => c.id === ev.category);
const dotStyle = cat ? `style="background:${cat.color};box-shadow:0 0 0 2px ${cat.color}"` : '';
// Duration bar
let barHtml = '';
if (ev.endDate && totalMs > 0) {
const durationMs = new Date(ev.endDate).getTime() - new Date(ev.date).getTime();
const barW = Math.max(16, (durationMs / totalMs) * totalWidth);
const barColor = cat ? cat.color : 'var(--accent)';
barHtml = `<div class="tl-duration-bar" style="left:calc(50% - 1px);width:${barW}px;background:${barColor}"></div>` +
`<div class="tl-duration-cap" style="left:calc(50% + ${barW - 1}px);background:${barColor}"></div>`;
}
const dateLabel = ev.endDate
? `${formatDate(ev.date, fmt)} ${formatDate(ev.endDate, fmt)}`
: formatDate(ev.date, fmt);
const isLink = !!ev.url;
const tag = isLink ? 'a' : 'div';
const attrs = isLink ? `href="${esc(ev.url)}" target="_blank" rel="noopener noreferrer"` : '';
const cls = above ? 'tl-card-above' : 'tl-card-below';
const thumbHtml = ev.thumbnail ? `<img class="tl-card-thumb" src="${ev.thumbnail}" alt="">` : '';
const descHtml = ev.description ? `<div class="tl-card-desc">${esc(ev.description)}</div>` : '';
const linkHint = isLink ? `<div class="tl-card-link-hint">↗ Open link</div>` : '';
const card = `<${tag} class="${cls}" ${attrs}>
${thumbHtml}
<div class="tl-card-date">${esc(dateLabel)}</div>
<div class="tl-card-title">${esc(ev.title)}</div>
${descHtml}${linkHint}
</${tag}>`;
wrap.innerHTML = above
? `<div class="tl-above-group">${card}<div class="tl-connector-above"></div></div>${barHtml}<div class="tl-dot" ${dotStyle}></div>`
: `<div class="tl-dot" ${dotStyle}></div>${barHtml}<div class="tl-below-group"><div class="tl-connector-below"></div>${card}</div>`;
eventsEl.appendChild(wrap);
});
// Today indicator
const todayMs = Date.now();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (todayMs >= firstDateMs - thirtyDays && todayMs <= lastDateMs + thirtyDays) {
const track = document.getElementById('timeline-track');
const todayX = totalMs > 0
? spacing / 2 + ((todayMs - firstDateMs) / totalMs) * totalWidth
: spacing / 2;
const todayEl = document.createElement('div');
todayEl.className = 'tl-today';
todayEl.style.left = Math.round(todayX) + 'px';
todayEl.innerHTML = `<div class="tl-today-pill">Today</div><div class="tl-today-line"></div>`;
track.appendChild(todayEl);
}
updateFocusedEvent();
}
// ── Event navigation ───────────────────────────────────────────────────────
function navigateEvent(delta) {
const tl = getCurrentTimeline();
if (!tl) return;
const count = sortedEvents(tl).length;
if (!count) return;
const newIndex = Math.max(0, Math.min(count - 1, focusedEventIndex + delta));
if (newIndex === focusedEventIndex) return;
const slideClass = delta > 0 ? 'slide-right' : 'slide-left';
focusedEventIndex = newIndex;
updateFocusedEvent(slideClass);
}
function updateFocusedEvent(slideClass) {
const wraps = document.querySelectorAll('.tl-event-wrap');
wraps.forEach(el => el.classList.remove('tl-focused', 'slide-right', 'slide-left'));
const focused = wraps[focusedEventIndex];
if (focused) {
if (slideClass) focused.classList.add(slideClass);
focused.classList.add('tl-focused');
focused.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}
// ── Presentation mode ──────────────────────────────────────────────────────
function enterPresentation() {
presentationMode = true;
document.documentElement.dataset.presenting = 'true';
}
function exitPresentation() {
presentationMode = false;
delete document.documentElement.dataset.presenting;
}
// ── Global keyboard handler ────────────────────────────────────────────────
document.addEventListener('keydown', e => {
const tag = document.activeElement?.tagName.toLowerCase();
const typing = tag === 'input' || tag === 'textarea';
if (state.view === 'view') {
if (e.key === 'ArrowRight') { e.preventDefault(); navigateEvent(1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); navigateEvent(-1); }
if (e.key === 'Escape' && presentationMode) exitPresentation();
if ((e.key === 'p' || e.key === 'P') && !typing && !presentationMode) enterPresentation();
}
});
// ── SVG Export ─────────────────────────────────────────────────────────────
function buildSVGString(tl, events) {
const fmt = tl.dateFormat || 'full';
const PAD_X = 60;
const TITLE_H = 64;
const SPACING = 220;
const SVG_H = 460;
const CY = TITLE_H + Math.round((SVG_H - TITLE_H) / 2);
const DOT_R = 7;
const CONN = 32;
const CARD_W = 180;
const CP = 12;
const IW = CARD_W - CP * 2;
const THUMB_H = 60;
const C = { bg:'#ffffff', surface:'#ffffff', border:'#d1d1d6',
text:'#1c1c1e', muted:'#6e6e73', accent:'#4f46e5' };
const SVG_W = PAD_X * 2 + events.length * SPACING;
// Duration bar metrics
const firstDateMs = new Date(events[0].date).getTime();
const lastDateMs = new Date(events[events.length - 1].date).getTime();
const totalSvgMs = lastDateMs - firstDateMs;
const totalSvgW = events.length > 1 ? (events.length - 1) * SPACING : SPACING;
function xe(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function wrapText(text, fs, maxW, maxLines) {
const cpl = Math.floor(maxW / (fs * 0.56));
const words = String(text||'').split(/\s+/).filter(Boolean);
const lines = []; let cur = '';
for (const w of words) {
const t = cur ? cur + ' ' + w : w;
if (t.length <= cpl) { cur = t; }
else { if (cur) lines.push(cur); cur = w.length > cpl ? w.slice(0, cpl-1)+'…' : w; if (lines.length >= maxLines-1) break; }
}
if (cur && lines.length < maxLines) lines.push(cur);
return lines;
}
function txt(content, ex, ey, fs, fill, weight) {
return `<text x="${ex}" y="${ey}" font-size="${fs}" fill="${fill}"${weight?` font-weight="${weight}"`:''}` +
` font-family="Segoe UI,Helvetica Neue,Arial,sans-serif">${xe(content)}</text>`;
}
const DATE_LH=16, TITLE_LH=18, DESC_LH=15, URL_LH=14;
let defs = '', evSvg = '';
events.forEach((ev, i) => {
const above = i % 2 === 0;
const cx = PAD_X + SPACING/2 + i * SPACING;
const hasThumb = !!ev.thumbnail;
// Category dot color
const cats = tl.categories || [];
const cat = cats.find(c => c.id === ev.category);
const dotColor = cat ? cat.color : C.accent;
const dateStr = ev.endDate
? `${formatDate(ev.date, fmt)} ${formatDate(ev.endDate, fmt)}`
: formatDate(ev.date, fmt);
const titleLns = wrapText(ev.title, 13, IW, 2);
const descLns = wrapText(ev.description, 11, IW, 3);
const hasDesc = descLns.length > 0;
const hasUrl = !!ev.url;
const urlShort = hasUrl
? '↗ '+ev.url.replace(/^https?:\/\/(www\.)?/,'').slice(0,30)+(ev.url.length>38?'…':'')
: '';
let cardH = CP
+ (hasThumb ? THUMB_H + 8 : 0)
+ DATE_LH
+ titleLns.length * TITLE_LH
+ (hasDesc ? 6 + descLns.length * DESC_LH : 0)
+ (hasUrl ? 4 + URL_LH : 0)
+ CP;
const cardX = cx - CARD_W/2;
let cardY, connY1, connY2;
if (above) {
cardY = CY - DOT_R - CONN - cardH;
connY1 = cardY + cardH; connY2 = CY - DOT_R;
} else {
cardY = CY + DOT_R + CONN;
connY1 = CY + DOT_R; connY2 = cardY;
}
if (hasThumb) {
defs += `<clipPath id="tc${i}"><rect x="${cardX}" y="${cardY}" width="${CARD_W}" height="${THUMB_H}" rx="8"/></clipPath>`;
}
const rect = `<rect x="${cardX}" y="${cardY}" width="${CARD_W}" height="${cardH}" rx="8" fill="${C.surface}" stroke="${C.border}" stroke-width="1" filter="url(#shadow)"/>`;
let thumbSvg = '';
if (hasThumb) {
const imgEl = `<image x="${cardX}" y="${cardY}" width="${CARD_W}" height="${THUMB_H}" href="${xe(ev.thumbnail)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#tc${i})"/>`;
thumbSvg = hasUrl ? `<a href="${xe(ev.url)}" target="_blank">${imgEl}</a>` : imgEl;
}
let ty = cardY + CP + (hasThumb ? THUMB_H + 8 : 0);
let texts = '';
ty += DATE_LH; texts += txt(dateStr, cardX+CP, ty, 11, C.muted, null);
titleLns.forEach(l => { ty += TITLE_LH; texts += txt(l, cardX+CP, ty, 13, C.text, '600'); });
if (hasDesc) { ty += 6; descLns.forEach(l => { ty += DESC_LH; texts += txt(l, cardX+CP, ty, 11, C.muted, null); }); }
if (hasUrl) { ty += 4+URL_LH; texts += txt(urlShort, cardX+CP, ty, 10, C.accent, null); }
const conn = `<line x1="${cx}" y1="${connY1}" x2="${cx}" y2="${connY2}" stroke="${C.border}" stroke-width="1.5" stroke-dasharray="4,3"/>`;
const dot = `<circle cx="${cx}" cy="${CY}" r="${DOT_R+4}" fill="${dotColor}" opacity="0.15"/>` +
`<circle cx="${cx}" cy="${CY}" r="${DOT_R}" fill="${dotColor}" stroke="white" stroke-width="2.5"/>`;
// Duration bar in SVG
let durSvg = '';
if (ev.endDate && totalSvgMs > 0) {
const dMs = new Date(ev.endDate).getTime() - new Date(ev.date).getTime();
const bw = Math.max(16, (dMs / totalSvgMs) * totalSvgW);
durSvg = `<rect x="${cx}" y="${CY - 3}" width="${bw}" height="6" rx="3" fill="${dotColor}" opacity="0.6"/>` +
`<circle cx="${cx + bw}" cy="${CY}" r="${DOT_R - 2}" fill="${dotColor}" stroke="white" stroke-width="2"/>`;
}
evSvg += `<g>${rect}${thumbSvg}${texts}${conn}${durSvg}${dot}</g>\n`;
});
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_W}" height="${SVG_H}" viewBox="0 0 ${SVG_W} ${SVG_H}">
<defs>
<filter id="shadow" x="-8%" y="-8%" width="116%" height="116%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000000" flood-opacity="0.09"/>
</filter>
${defs}
</defs>
<rect width="${SVG_W}" height="${SVG_H}" fill="${C.bg}"/>
${txt(tl.name, PAD_X, 36, 20, C.text, '700')}
${txt(`${events.length} event${events.length!==1?'s':''} · ${dateRange(tl)}`, PAD_X, 54, 12, C.muted, null)}
<line x1="${PAD_X}" y1="${CY}" x2="${SVG_W-PAD_X}" y2="${CY}" stroke="${C.border}" stroke-width="2"/>
${evSvg}
</svg>`;
}
function exportSVG() {
const tl = getCurrentTimeline();
if (!tl) return;
const events = sortedEvents(tl);
if (!events.length) { toast('No events to export.'); return; }
const svg = buildSVGString(tl, events);
download(`timeline-${tl.name.replace(/[^a-z0-9]/gi,'_').toLowerCase()}.svg`, svg, 'image/svg+xml;charset=utf-8');
toast('SVG exported — insert via PowerPoint: Insert → Pictures → This Device.');
}
function exportPNG() {
const tl = getCurrentTimeline();
if (!tl) return;
const events = sortedEvents(tl);
if (!events.length) { toast('No events to export.'); return; }
toast('Generating PNG…');
const svgStr = buildSVGString(tl, events);
const blob = new Blob([svgStr], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const w = img.naturalWidth || 800;
const h = img.naturalHeight || 460;
const c = document.createElement('canvas');
c.width = w * 2; c.height = h * 2;
const ctx = c.getContext('2d');
ctx.scale(2, 2);
ctx.drawImage(img, 0, 0, w, h);
URL.revokeObjectURL(url);
c.toBlob(b => downloadBlob(b, tl.name.replace(/[^a-z0-9]/gi,'_').toLowerCase() + '.png'), 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); toast('PNG export failed.'); };
img.src = url;
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
// ── Escape HTML ────────────────────────────────────────────────────────────
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ── Init ───────────────────────────────────────────────────────────────────
function init() { initTheme(); loadState(); navTo('list'); }
init();
</script>
</body>
</html>