- 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>
1609 lines
71 KiB
HTML
1609 lines
71 KiB
HTML
<!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 · <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' : ''} · ${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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
function init() { initTheme(); loadState(); navTo('list'); }
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|