diff --git a/src/server/request-handler.ts b/src/server/request-handler.ts index 27c818b..e30a655 100644 --- a/src/server/request-handler.ts +++ b/src/server/request-handler.ts @@ -485,11 +485,28 @@ function htmlPage(): string { min-height: 170px; padding: 14px 12px 10px; border-radius: 16px; - border: 1px solid rgba(15, 23, 42, 0.08); - background: rgba(255, 255, 255, 0.55); + border: 1px solid rgba(148, 163, 184, 0.18); + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(15, 23, 42, 0.88)), + radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 42%); overflow: hidden; touch-action: none; user-select: none; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + inset 0 -1px 0 rgba(255, 255, 255, 0.03); + } + + .timeline-chart::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(to top, rgba(255, 255, 255, 0.05) 1px, transparent 1px), + linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 100% 33.333%, 10% 100%; + opacity: 0.35; + pointer-events: none; } .timeline-bars { @@ -498,7 +515,9 @@ function htmlPage(): string { grid-auto-flow: column; grid-auto-columns: minmax(10px, 1fr); align-items: end; - gap: 3px; + gap: 2px; + position: relative; + z-index: 1; } .timeline-bar { @@ -507,33 +526,37 @@ function htmlPage(): string { display: grid; align-items: end; cursor: pointer; - border-radius: 8px 8px 4px 4px; + border-radius: 6px 6px 3px 3px; } .timeline-bar-fill { width: 100%; border-radius: inherit; min-height: 4px; - background: linear-gradient(180deg, rgba(45, 108, 223, 0.92), rgba(45, 108, 223, 0.36)); - transition: transform 140ms ease, background 140ms ease, opacity 140ms ease; + background: linear-gradient(180deg, rgba(96, 165, 250, 0.98), rgba(59, 130, 246, 0.36)); + transition: transform 140ms ease, background 140ms ease, opacity 140ms ease, box-shadow 140ms ease; transform-origin: bottom; + box-shadow: 0 0 0 1px rgba(30, 64, 175, 0.28); } .timeline-bar:hover .timeline-bar-fill, .timeline-bar.active .timeline-bar-fill { - background: linear-gradient(180deg, #f97316, #f59e0b); + background: linear-gradient(180deg, #38bdf8, #0ea5e9); + box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.38), 0 0 18px rgba(14, 165, 233, 0.18); } .timeline-bar.selected .timeline-bar-fill { - background: linear-gradient(180deg, #16a34a, #86efac); + background: linear-gradient(180deg, #22c55e, #86efac); + box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.35), 0 0 18px rgba(34, 197, 94, 0.18); } .timeline-bar.active .timeline-bar-fill { - box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.18); + transform: translateY(-1px); } .timeline-bar.selected.active .timeline-bar-fill { - background: linear-gradient(180deg, #059669, #34d399); + background: linear-gradient(180deg, #34d399, #10b981); + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.38), 0 0 22px rgba(16, 185, 129, 0.24); } .timeline-bar-meta { @@ -543,7 +566,8 @@ function htmlPage(): string { transform: translateX(-50%); white-space: nowrap; font-size: 0.72rem; - color: var(--muted); + color: rgba(226, 232, 240, 0.82); + font-variant-numeric: tabular-nums; pointer-events: none; } @@ -551,6 +575,7 @@ function htmlPage(): string { position: relative; height: 20px; margin-top: -2px; + color: rgba(226, 232, 240, 0.72); } .timeline-axis-label { @@ -558,7 +583,8 @@ function htmlPage(): string { top: 0; transform: translateX(-50%); font-size: 0.72rem; - color: var(--muted); + color: rgba(226, 232, 240, 0.78); + font-variant-numeric: tabular-nums; white-space: nowrap; } @@ -567,11 +593,16 @@ function htmlPage(): string { top: 14px; bottom: 34px; border-radius: 10px; - border: 1px solid rgba(5, 150, 105, 0.45); - background: rgba(5, 150, 105, 0.16); + border: 1px solid rgba(56, 189, 248, 0.55); + background: + linear-gradient(180deg, rgba(56, 189, 248, 0.18), rgba(14, 165, 233, 0.08)), + rgba(14, 165, 233, 0.12); pointer-events: none; opacity: 0; transition: opacity 120ms ease; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.06), + 0 0 22px rgba(14, 165, 233, 0.12); } .timeline-selection.visible { @@ -583,10 +614,11 @@ function htmlPage(): string { top: 14px; bottom: 34px; border-radius: 10px; - border: 1px dashed rgba(45, 108, 223, 0.6); - background: rgba(45, 108, 223, 0.12); + border: 1px dashed rgba(56, 189, 248, 0.9); + background: rgba(56, 189, 248, 0.16); pointer-events: none; opacity: 0; + box-shadow: 0 0 20px rgba(56, 189, 248, 0.12); } .timeline-brush.visible { @@ -596,6 +628,11 @@ function htmlPage(): string { .timeline-footer { font-size: 0.84rem; color: var(--muted); + font-variant-numeric: tabular-nums; + } + + .timeline-footer span:last-child { + color: rgba(51, 65, 85, 0.92); } @media (max-width: 920px) { @@ -743,6 +780,7 @@ function htmlPage(): string { total: 0, activePhotoId: null, timelineSelection: null, + timelineViewport: null, timeline: { unit: "day", bins: [], @@ -1002,7 +1040,7 @@ function htmlPage(): string { ); } - function buildTimeline(photos) { + function buildTimeline(photos, viewport = null) { const datedPhotos = photos .map((photo) => ({ photo, date: getPhotoTimestamp(photo) })) .filter((entry) => entry.date !== null) @@ -1017,8 +1055,10 @@ function htmlPage(): string { }; } - const startDate = datedPhotos[0].date; - const endDate = datedPhotos[datedPhotos.length - 1].date; + const dataStart = datedPhotos[0].date; + const dataEnd = datedPhotos[datedPhotos.length - 1].date; + const startDate = viewport ? new Date(viewport.start) : dataStart; + const endDate = viewport ? new Date(viewport.end) : dataEnd; const spanMs = Math.max(1, endDate.getTime() - startDate.getTime()); const unit = chooseTimelineUnit(spanMs); const domainStart = getUnitStart(startDate, unit); @@ -1042,6 +1082,10 @@ function htmlPage(): string { }); for (const entry of datedPhotos) { + if (viewport && (entry.date.getTime() < viewport.start || entry.date.getTime() >= viewport.end)) { + continue; + } + const bucketStart = getUnitStart(entry.date, unit).getTime(); const binIndex = binIndexByStart.get(bucketStart); if (binIndex !== undefined) { @@ -1090,6 +1134,7 @@ function htmlPage(): string { state.visiblePhotos = []; state.activePhotoId = null; state.timelineSelection = null; + state.timelineViewport = null; state.timeline = { unit: "day", bins: [], @@ -1193,6 +1238,7 @@ function htmlPage(): string { function setTimelineSelection(start, end) { if (start === null || end === null) { state.timelineSelection = null; + state.timelineViewport = null; timelineClear.disabled = true; } else { const normalizedStart = Math.min(start, end); @@ -1201,6 +1247,10 @@ function htmlPage(): string { start: normalizedStart, end: normalizedEnd }; + state.timelineViewport = { + start: normalizedStart, + end: normalizedEnd + }; timelineClear.disabled = false; } @@ -1212,10 +1262,11 @@ function htmlPage(): string { } function renderTimeline() { - const timeline = buildTimeline(state.photos); + const timelinePhotos = state.timelineSelection ? state.visiblePhotos : state.photos; + const timeline = buildTimeline(timelinePhotos, state.timelineViewport); state.timeline = timeline; timelineSummary.textContent = formatTimelineSummary(state.photos, timeline.unit); - timelineUnit.textContent = "Scale: " + timeline.unit; + timelineUnit.textContent = "Scale: " + timeline.unit + (state.timelineSelection ? " ยท zoomed" : ""); timelineBars.replaceChildren(); timelineAxis.replaceChildren();