feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library: - Connects to IMAP server (IMAPFlow, TLS on port 993) - Fetches recent messages (configurable limit, default 20) - Extracts: from, fromEmail, subject, date, snippet (200 chars) - Thread-to-contact matching (PRD §6.1): 1. Match sender email against persons table 2. If no person match, extract domain and match against company.website 3. Returns matchedCompany + matchedPerson for each email - Audit-logged (action: crm.mailsync:imap) - Zod validated (host, port, user, password, folder, limit) - 502 on IMAP connection failure with detail - 400 on validation failure Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
@@ -0,0 +1,909 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>FIELD NOTES — Desert Archive · Vol. 01</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--ink: #111111;
|
||||
--ink-soft: rgba(17, 17, 17, 0.62);
|
||||
--ink-mute: rgba(17, 17, 17, 0.38);
|
||||
--line: rgba(17, 17, 17, 0.12);
|
||||
--line-soft: rgba(17, 17, 17, 0.06);
|
||||
|
||||
--display: "Fraunces", "Iowan Old Style", "Apple Garamond", "Source Han Serif SC", Georgia, serif;
|
||||
--sans: "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", Helvetica, Arial, sans-serif;
|
||||
|
||||
/* easings — Pavilion uses two */
|
||||
--ease-1: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--ease-2: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
/* ============ FIXED HEADER ============ */
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 22px; left: 24px; right: 24px;
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
font-family: var(--sans);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.brand {
|
||||
pointer-events: auto;
|
||||
display: flex; flex-direction: column;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.brand .b-mark { font-family: var(--display); font-style: italic; font-weight: 500; font-size: 18px; letter-spacing: -0.01em; line-height: 1; margin-bottom: 6px; }
|
||||
.brand .b-line { color: var(--ink-soft); }
|
||||
.brand .b-line.dim { color: var(--ink-mute); }
|
||||
|
||||
.menu {
|
||||
pointer-events: auto;
|
||||
display: flex; gap: 24px; align-items: center;
|
||||
}
|
||||
.menu a, .menu button {
|
||||
color: var(--ink);
|
||||
background: none; border: 0;
|
||||
text-decoration: none;
|
||||
font: inherit; letter-spacing: inherit; text-transform: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.menu a:hover, .menu button:hover { border-bottom-color: var(--ink); }
|
||||
.menu .sep { width: 4px; height: 4px; border-radius: 50%; background: var(--ink); display: inline-block; }
|
||||
|
||||
/* ============ PAGE (Pavilion: flex row, 12px gap, 50/50) ============ */
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100svh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ============ STAGE (left 50%) ============ */
|
||||
.stage {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 96px 0 96px 24px;
|
||||
}
|
||||
.stage-inner {
|
||||
position: relative;
|
||||
width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
background: #f8f8f7;
|
||||
}
|
||||
|
||||
.asset {
|
||||
position: absolute; inset: 0;
|
||||
transition: transform .75s var(--ease-1), opacity .35s var(--ease-1);
|
||||
will-change: transform, opacity;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.asset.is-active { transform: translateY(0); opacity: 1; }
|
||||
.asset.is-prev { transform: translateY(-100%); opacity: 0; }
|
||||
.asset.is-next { transform: translateY(100%); opacity: 0; }
|
||||
.asset.is-far-prev { transform: translateY(-100%); opacity: 0; }
|
||||
.asset.is-far-next { transform: translateY(100%); opacity: 0; }
|
||||
|
||||
.asset img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover; display: block;
|
||||
filter: contrast(1.02) saturate(0.94);
|
||||
}
|
||||
|
||||
/* film-frame marks */
|
||||
.frame-marks {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
}
|
||||
.frame-marks span {
|
||||
position: absolute;
|
||||
width: 14px; height: 14px;
|
||||
border: 1px solid rgba(255,255,255,0.55);
|
||||
}
|
||||
.frame-marks .tl { top: 14px; left: 14px; border-right: 0; border-bottom: 0; }
|
||||
.frame-marks .tr { top: 14px; right: 14px; border-left: 0; border-bottom: 0; }
|
||||
.frame-marks .bl { bottom: 14px; left: 14px; border-right: 0; border-top: 0; }
|
||||
.frame-marks .br { bottom: 14px; right: 14px; border-left: 0; border-top: 0; }
|
||||
|
||||
.photo-caption {
|
||||
position: absolute;
|
||||
left: 28px; bottom: 26px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-family: var(--sans);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 24px rgba(0,0,0,0.5);
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
}
|
||||
.photo-caption .pc-row { display: flex; gap: 14px; align-items: center; }
|
||||
.photo-caption .pc-row .pc-bar { width: 18px; height: 1px; background: rgba(255,255,255,0.7); }
|
||||
.photo-caption .pc-val {
|
||||
position: relative;
|
||||
min-width: 1ch;
|
||||
}
|
||||
.photo-caption .pc-val span {
|
||||
position: absolute; left: 0; top: 0;
|
||||
opacity: 0;
|
||||
transition: opacity .35s var(--ease-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.photo-caption .pc-val span.is-active { opacity: 1; position: relative; }
|
||||
|
||||
/* ============ INFO (right 50%) ============ */
|
||||
.info {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 96px 24px 96px 0;
|
||||
}
|
||||
|
||||
/* facts strip — top right */
|
||||
.facts {
|
||||
position: absolute;
|
||||
top: 96px; right: 220px;
|
||||
display: flex; gap: 24px;
|
||||
font-family: var(--sans);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
max-width: calc(100% - 240px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.facts .fact {
|
||||
display: flex; gap: 8px; align-items: baseline;
|
||||
position: relative;
|
||||
}
|
||||
.facts .fact .k { color: var(--ink-mute); }
|
||||
.facts .fact .v {
|
||||
position: relative;
|
||||
min-width: 4ch;
|
||||
color: var(--ink);
|
||||
}
|
||||
.facts .fact .v span {
|
||||
position: absolute; left: 0; top: 0;
|
||||
opacity: 0;
|
||||
transition: opacity .35s var(--ease-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.facts .fact .v span.is-active { opacity: 1; position: relative; }
|
||||
|
||||
/* project sub-info — moved to BOTTOM-LEFT to clear scaled title (中文版本会变宽,避免叠住) */
|
||||
.project-info {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
bottom: 88px;
|
||||
left: 0;
|
||||
transform: none;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 4px;
|
||||
font-family: var(--sans);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
min-height: 24px;
|
||||
z-index: 2;
|
||||
max-width: 42%;
|
||||
}
|
||||
.pi-line {
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pi-line span {
|
||||
display: block;
|
||||
position: absolute; left: 0; top: 0;
|
||||
opacity: 0;
|
||||
transition: opacity .35s var(--ease-1);
|
||||
color: var(--ink);
|
||||
}
|
||||
.pi-line span.is-active { opacity: 1; position: relative; }
|
||||
.pi-line.dim span { color: var(--ink-mute); }
|
||||
|
||||
/* titles track — Pavilion: full-height, position:relative */
|
||||
.titles-track {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* dial — Pavilion: top:50%, right:0, translate(50%, -50%), half off-screen */
|
||||
.dial {
|
||||
--count: 16;
|
||||
--radius: 96px;
|
||||
width: calc(var(--radius) * 2);
|
||||
height: calc(var(--radius) * 2);
|
||||
position: absolute;
|
||||
top: 50%; right: 0;
|
||||
transform: translate(50%, -50%) rotate(var(--dial-rotation, 135deg));
|
||||
transition: all .75s var(--ease-1);
|
||||
pointer-events: none;
|
||||
color: var(--ink);
|
||||
z-index: 1;
|
||||
}
|
||||
.dot {
|
||||
width: var(--s, 6px); height: var(--s, 6px);
|
||||
opacity: var(--o, 1);
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%)
|
||||
rotate(calc(180deg + (var(--i) * 360deg / var(--count))))
|
||||
translateX(var(--radius));
|
||||
transform-origin: 50%;
|
||||
transition: all .35s var(--ease-1);
|
||||
}
|
||||
|
||||
/* slide (title wheel) — Pavilion: absolute, top:50%, right:0, transform-origin:100%, padding-right:192px */
|
||||
.slide {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform-origin: 100%;
|
||||
transition: transform .75s var(--ease-1), opacity .35s var(--ease-1);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding-right: 192px;
|
||||
will-change: transform, opacity;
|
||||
pointer-events: none;
|
||||
}
|
||||
.slide.is-active { pointer-events: auto; }
|
||||
|
||||
.slide .title {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
transition: opacity .2s var(--ease-2);
|
||||
}
|
||||
.slide .title:hover { opacity: 0.5; }
|
||||
.slide .title > * {
|
||||
transition: all .35s var(--ease-1);
|
||||
text-align: right;
|
||||
transform-origin: 100%;
|
||||
white-space: nowrap;
|
||||
line-height: 100%;
|
||||
}
|
||||
.slide.is-active .title > * { transition-delay: 50ms; }
|
||||
|
||||
.title-text {
|
||||
font-family: var(--display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.slide.is-active .title-text {
|
||||
transform: scale(1.85);
|
||||
}
|
||||
|
||||
.title-num {
|
||||
font-family: var(--sans);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--ink-mute);
|
||||
height: fit-content;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
.slide.is-active .title-num { width: 30px; opacity: 1; }
|
||||
|
||||
/* description ribbon — Pavilion: bottom-right, single-line ticker */
|
||||
.description {
|
||||
position: absolute;
|
||||
bottom: 24px; right: 24px;
|
||||
width: calc(50% - 36px);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1px;
|
||||
z-index: 2;
|
||||
}
|
||||
.desc-text, .description > a {
|
||||
background-color: var(--ink);
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--sans);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.5;
|
||||
height: 28px;
|
||||
}
|
||||
.desc-text {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.desc-text p {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
color: #fff;
|
||||
width: calc(100% - 20px);
|
||||
min-width: 0;
|
||||
transition: opacity .35s var(--ease-1);
|
||||
position: absolute;
|
||||
left: 10px; top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.desc-text p.is-active { opacity: 1; }
|
||||
|
||||
.description > a {
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
width: fit-content;
|
||||
display: inline-flex; gap: 10px; align-items: center;
|
||||
height: 28px;
|
||||
}
|
||||
.description > a span {
|
||||
transition: opacity .2s var(--ease-2);
|
||||
}
|
||||
.description > a:hover span { opacity: 0.5; }
|
||||
|
||||
/* ============ FOOTER ============ */
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 22px; left: 24px; right: 24px;
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
font-family: var(--sans);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.counter {
|
||||
display: flex; gap: 12px; align-items: baseline;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
.counter .cur {
|
||||
font-family: var(--display);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 40px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.02em;
|
||||
min-width: 48px; display: inline-block;
|
||||
}
|
||||
.counter .slash, .counter .tot { color: var(--ink-mute); padding-bottom: 3px; }
|
||||
.counter .label { padding-bottom: 5px; margin-left: 8px; color: var(--ink-mute); }
|
||||
|
||||
.nav-group {
|
||||
pointer-events: auto;
|
||||
display: flex; gap: 14px; align-items: center;
|
||||
}
|
||||
.nav-group .hint { color: var(--ink-mute); }
|
||||
.nav-group button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--ink);
|
||||
color: var(--ink);
|
||||
width: 38px; height: 38px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.25s var(--ease-1), color 0.25s var(--ease-1), transform 0.15s var(--ease-1);
|
||||
font: inherit;
|
||||
}
|
||||
.nav-group button:hover { background: var(--ink); color: var(--bg); }
|
||||
.nav-group button:active { transform: scale(0.94); }
|
||||
|
||||
/* progress strip — above footer right */
|
||||
.progress {
|
||||
position: fixed;
|
||||
bottom: 76px; right: 24px;
|
||||
width: 220px;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
display: flex; gap: 4px;
|
||||
}
|
||||
.progress span {
|
||||
flex: 1; height: 1px;
|
||||
background: var(--line);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress span::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: var(--ink);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
transition: transform .75s var(--ease-1);
|
||||
}
|
||||
.progress span.passed::after, .progress span.active::after { transform: scaleX(1); }
|
||||
|
||||
.kbd {
|
||||
position: fixed;
|
||||
left: 50%; bottom: 22px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
font-family: var(--sans);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
pointer-events: none;
|
||||
}
|
||||
.kbd kbd {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 9.5px;
|
||||
color: var(--ink-soft);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stage { cursor: grab; }
|
||||
.stage.dragging { cursor: grabbing; }
|
||||
|
||||
/* ============ MOBILE (Pavilion: stack column, photo 66%, info 33%) ============ */
|
||||
@media (max-width: 860px) {
|
||||
.page {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.stage { width: 100%; height: 60%; padding: 56px 12px 0 12px; }
|
||||
.info { width: 100%; height: 40%; padding: 0 12px 60px 12px; }
|
||||
|
||||
.header { top: 12px; left: 14px; right: 14px; }
|
||||
.footer { left: 14px; right: 14px; bottom: 14px; }
|
||||
.brand .b-line { display: none; }
|
||||
.menu { gap: 14px; }
|
||||
|
||||
.facts { display: none; }
|
||||
.project-info { display: none; }
|
||||
|
||||
/* slides: Pavilion mobile uses transform-origin:bottom, center-aligned */
|
||||
.slide {
|
||||
transform-origin: bottom;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-right: 0;
|
||||
left: 50%; right: auto;
|
||||
top: auto; bottom: 50%;
|
||||
}
|
||||
.slide .title { flex-direction: column-reverse; justify-content: center; align-items: center; }
|
||||
.slide .title > * { text-align: center; transform-origin: bottom; }
|
||||
.title-text { font-size: 22px; }
|
||||
.slide.is-active .title-text { transform: scale(1.5); }
|
||||
|
||||
.dial {
|
||||
--radius: 64px;
|
||||
top: calc(60% - 60px);
|
||||
right: 50%;
|
||||
transform: translate(50%, -50%) rotate(calc(var(--dial-rotation,135deg) + 270deg));
|
||||
}
|
||||
|
||||
.description {
|
||||
bottom: 60px; right: 12px; left: 12px;
|
||||
width: auto;
|
||||
}
|
||||
.description > a { text-align: center; width: 100%; justify-content: center; }
|
||||
|
||||
.progress { display: none; }
|
||||
.kbd { display: none; }
|
||||
.counter .cur { font-size: 28px; min-width: 36px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<span class="b-mark">Field Notes</span>
|
||||
<span class="b-line">Desert Archive · Vol. 01</span>
|
||||
<span class="b-line dim">Est. 2023 — Ongoing</span>
|
||||
</div>
|
||||
<nav class="menu">
|
||||
<a href="#" data-act="index">Index</a>
|
||||
<a href="#" data-act="map">Map</a>
|
||||
<a href="#" data-act="about">About</a>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-act="subscribe">Subscribe</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
|
||||
<!-- LEFT 50%: photo stage -->
|
||||
<section class="stage" id="stage" aria-label="Photo carousel">
|
||||
<div class="stage-inner" id="stageInner">
|
||||
<!-- assets injected by JS -->
|
||||
<div class="frame-marks" aria-hidden="true">
|
||||
<span class="tl"></span><span class="tr"></span>
|
||||
<span class="bl"></span><span class="br"></span>
|
||||
</div>
|
||||
<div class="photo-caption" aria-hidden="true">
|
||||
<div class="pc-row"><span class="pc-bar"></span><span id="pcFrame">FRAME 01 / 12</span></div>
|
||||
<div class="pc-val" id="pcLoc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT 50%: info -->
|
||||
<aside class="info">
|
||||
|
||||
<!-- top-right facts strip -->
|
||||
<div class="facts" aria-label="Frame metadata">
|
||||
<div class="fact"><span class="k">Date</span><span class="v" id="factDate"></span></div>
|
||||
<div class="fact"><span class="k">Format</span><span class="v" id="factFmt"></span></div>
|
||||
<div class="fact"><span class="k">Time</span><span class="v" id="factTime"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- LEFT-CENTER (per Pavilion) sub label -->
|
||||
<div class="project-info">
|
||||
<div class="pi-line dim" id="piNum"></div>
|
||||
<div class="pi-line" id="piSub"></div>
|
||||
<div class="pi-line dim" id="piLoc"></div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT-CENTER title wheel -->
|
||||
<div class="titles-track" id="titlesTrack"></div>
|
||||
|
||||
<!-- RIGHT EDGE dial protruding -->
|
||||
<div class="dial" id="dial" aria-hidden="true"></div>
|
||||
|
||||
<!-- BOTTOM-RIGHT description ribbon (Pavilion) -->
|
||||
<div class="description">
|
||||
<div class="desc-text" id="descText">
|
||||
<!-- desc <p>s injected -->
|
||||
</div>
|
||||
<a href="#" id="cta"><span>View Frame</span><span>→</span></a>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="counter">
|
||||
<span class="cur" id="cur">01</span>
|
||||
<span class="slash">/</span>
|
||||
<span class="tot" id="tot">12</span>
|
||||
<span class="label">Frame</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-group" aria-label="Carousel navigation">
|
||||
<span class="hint">Drag · Scroll · ← →</span>
|
||||
<button type="button" id="prev" aria-label="Previous frame">
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none"><path d="M5 1L1 5L5 9M1 5H13" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</button>
|
||||
<button type="button" id="next" aria-label="Next frame">
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none"><path d="M9 1L13 5L9 9M13 5H1" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="progress" id="progress" aria-hidden="true"></div>
|
||||
|
||||
<div class="kbd" aria-hidden="true">
|
||||
Navigate <kbd>←</kbd> <kbd>→</kbd> · Jump <kbd>1–9</kbd>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const IMAGES = {"01-golden-dunes-sunrise-31415631.jpg": "data:image/jpeg;base64,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 truncated
|
||||
|
||||
const slides = [
|
||||
{ f:"01-golden-dunes-sunrise-31415631.jpg", title:"Golden Crest", sub:"Sunrise Expedition",
|
||||
desc:"First light grazes the slip-face at 06:14",
|
||||
loc:"Erg Chebbi, Morocco", date:"MAR 2024", fmt:"Color Negative", time:"06:14" },
|
||||
{ f:"02-camel-caravan-8227510.jpg", title:"The Caravan", sub:"Old Salt Route",
|
||||
desc:"Six camels · two days · no water between",
|
||||
loc:"Tinejdad → Mhamid", date:"NOV 2023", fmt:"Color Negative", time:"15:42" },
|
||||
{ f:"03-lonely-tree-36893821.jpg", title:"Lone Acacia", sub:"A Single Anchor",
|
||||
desc:"Roots reach seven meters to a vanished river",
|
||||
loc:"Tassili n'Ajjer", date:"APR 2024", fmt:"Medium Format", time:"11:08" },
|
||||
{ f:"04-aerial-dunes-10390368.jpg", title:"From Above", sub:"Aerial Survey",
|
||||
desc:"Four hundred feet, the dunes resolve into prints",
|
||||
loc:"Namib-Naukluft", date:"JUL 2025", fmt:"Digital · Drone", time:"08:30" },
|
||||
{ f:"05-endless-dunes-16908866.jpg", title:"Quiet Vastness", sub:"Horizon Study",
|
||||
desc:"By the third hour the eye invents its own coords",
|
||||
loc:"Empty Quarter", date:"JAN 2025", fmt:"Color Negative", time:"14:20" },
|
||||
{ f:"06-sunlit-dunes-28911994.jpg", title:"Half Asleep", sub:"Afternoon Study",
|
||||
desc:"Three to four — sand becomes a chiaroscuro",
|
||||
loc:"Sossusvlei, Namibia", date:"AUG 2024", fmt:"Color Slide", time:"15:55" },
|
||||
{ f:"07-sahara-dune-11377054.jpg", title:"Soft Ripples", sub:"Wind Drawing",
|
||||
desc:"Night wind redraws the surface, daily, unread",
|
||||
loc:"Sahara, Algeria", date:"FEB 2025", fmt:"Medium Format", time:"07:02" },
|
||||
{ f:"08-sunset-hills-road-20685425.jpg", title:"Road West", sub:"Last Light",
|
||||
desc:"A line that promises nothing in particular",
|
||||
loc:"Atacama, Chile", date:"SEP 2024", fmt:"Color Negative", time:"18:47" },
|
||||
{ f:"09-rocky-terrain-28843252.jpg", title:"Ancient Stone", sub:"Geologic Time",
|
||||
desc:"Older than the sand by 280 million years",
|
||||
loc:"Wadi Rum, Jordan", date:"MAY 2024", fmt:"Black & White", time:"10:15" },
|
||||
{ f:"10-saguaro-cactus-4061938.jpg", title:"Desert Guardian", sub:"Solitary Form",
|
||||
desc:"First arm at around seventy-five years",
|
||||
loc:"Sonoran, Arizona", date:"OCT 2023", fmt:"Color Slide", time:"17:30" },
|
||||
{ f:"11-acacia-palms-28170329.jpg", title:"Oasis Edge", sub:"Living Boundary",
|
||||
desc:"Palms end, sand begins — six degrees lost",
|
||||
loc:"Siwa, Egypt", date:"DEC 2024", fmt:"Color Negative", time:"09:25" },
|
||||
{ f:"12-vast-dunes-2106194.jpg", title:"Closing Frame", sub:"Infinite Field",
|
||||
desc:"The desert refuses the last word",
|
||||
loc:"Rub' al Khali", date:"JUN 2025", fmt:"Color Negative", time:"16:10" }
|
||||
];
|
||||
const N = slides.length;
|
||||
let current = 0;
|
||||
let isAnimating = false;
|
||||
|
||||
/* ===== stage: build assets (12, absolute stacked) ===== */
|
||||
const stageInner = document.getElementById("stageInner");
|
||||
slides.forEach((s, i) => {
|
||||
const a = document.createElement("div");
|
||||
a.className = "asset";
|
||||
a.dataset.idx = i;
|
||||
a.innerHTML = `<img src="${IMAGES[s.f]}" alt="${s.title}" loading="${i < 3 ? 'eager' : 'lazy'}">`;
|
||||
stageInner.insertBefore(a, stageInner.firstChild);
|
||||
});
|
||||
const assetEls = stageInner.querySelectorAll(".asset");
|
||||
|
||||
/* ===== titles track: build slides ===== */
|
||||
const track = document.getElementById("titlesTrack");
|
||||
slides.forEach((s, i) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "slide";
|
||||
el.dataset.idx = i;
|
||||
el.innerHTML = `
|
||||
<a class="title" href="#" data-idx="${i}">
|
||||
<span class="title-text">${s.title}</span>
|
||||
<span class="title-num">[${String(i+1).padStart(2,"0")}]</span>
|
||||
</a>`;
|
||||
track.appendChild(el);
|
||||
});
|
||||
const slideEls = track.querySelectorAll(".slide");
|
||||
|
||||
/* ===== dial dots (Pavilion 16-dot ring) ===== */
|
||||
const dial = document.getElementById("dial");
|
||||
const sizes = [2,2,2,2,2,2,3,5,8,10,12,10,8,5,3,2];
|
||||
const opacs = [0,0,0,0,0,0,0.08,0.16,0.32,0.55,1,0.55,0.32,0.16,0.08,0];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "dot";
|
||||
dot.style.setProperty("--i", i);
|
||||
dot.style.setProperty("--s", sizes[i] + "px");
|
||||
dot.style.setProperty("--o", opacs[i]);
|
||||
dial.appendChild(dot);
|
||||
}
|
||||
|
||||
/* ===== project-info lines (each is a stack of spans) ===== */
|
||||
function buildStack(el, values, prefix = "", suffix = "") {
|
||||
el.innerHTML = "";
|
||||
values.forEach((v, i) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = prefix + v + suffix;
|
||||
span.dataset.idx = i;
|
||||
if (i === 0) span.classList.add("is-active");
|
||||
el.appendChild(span);
|
||||
});
|
||||
}
|
||||
const piNum = document.getElementById("piNum");
|
||||
const piSub = document.getElementById("piSub");
|
||||
const piLoc = document.getElementById("piLoc");
|
||||
buildStack(piNum, slides.map((_, i) => "Frame " + String(i+1).padStart(2,"0")));
|
||||
buildStack(piSub, slides.map(s => s.sub));
|
||||
buildStack(piLoc, slides.map(s => s.loc));
|
||||
|
||||
/* ===== facts stack ===== */
|
||||
const factDate = document.getElementById("factDate");
|
||||
const factFmt = document.getElementById("factFmt");
|
||||
const factTime = document.getElementById("factTime");
|
||||
buildStack(factDate, slides.map(s => s.date));
|
||||
buildStack(factFmt, slides.map(s => s.fmt));
|
||||
buildStack(factTime, slides.map(s => s.time));
|
||||
|
||||
/* ===== photo caption loc stack ===== */
|
||||
const pcLoc = document.getElementById("pcLoc");
|
||||
buildStack(pcLoc, slides.map(s => s.loc));
|
||||
|
||||
/* ===== description ribbon ===== */
|
||||
const descText = document.getElementById("descText");
|
||||
slides.forEach((s, i) => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = s.desc;
|
||||
p.dataset.idx = i;
|
||||
if (i === 0) p.classList.add("is-active");
|
||||
descText.appendChild(p);
|
||||
});
|
||||
|
||||
/* ===== progress ticks ===== */
|
||||
const progress = document.getElementById("progress");
|
||||
for (let i = 0; i < N; i++) progress.appendChild(document.createElement("span"));
|
||||
const progEls = progress.querySelectorAll("span");
|
||||
|
||||
document.getElementById("tot").textContent = String(N).padStart(2, "0");
|
||||
const pcFrame = document.getElementById("pcFrame");
|
||||
const cur = document.getElementById("cur");
|
||||
|
||||
/* ===== modular offset helper ===== */
|
||||
function modOffset(i, c, n) {
|
||||
let off = i - c;
|
||||
if (off > n / 2) off -= n;
|
||||
if (off < -n / 2) off += n;
|
||||
return off;
|
||||
}
|
||||
|
||||
/* ===== render ===== */
|
||||
function render() {
|
||||
// title wheel — Pavilion's exact transform values
|
||||
slideEls.forEach((el, i) => {
|
||||
const off = modOffset(i, current, N);
|
||||
let ty, rot, op;
|
||||
if (off === 0) { ty = -50; rot = 0; op = 1; }
|
||||
else if (off === -1){ ty = -150; rot = 45; op = 0.33; }
|
||||
else if (off === 1) { ty = 50; rot = -45; op = 0.33; }
|
||||
else if (off === -2){ ty = -250; rot = 90; op = 0; }
|
||||
else if (off === 2) { ty = 150; rot = -90; op = 0; }
|
||||
else {
|
||||
// far-out slides — pinned beyond ±2
|
||||
const dir = off < 0 ? -1 : 1;
|
||||
ty = dir === -1 ? -250 : 150;
|
||||
rot = dir === -1 ? 90 : -90;
|
||||
op = 0;
|
||||
}
|
||||
el.style.transform = `translateY(${ty}%) rotate(${rot}deg)`;
|
||||
el.style.opacity = op;
|
||||
el.classList.toggle("is-active", off === 0);
|
||||
});
|
||||
|
||||
// stage assets — modular slide
|
||||
assetEls.forEach((el, i) => {
|
||||
const off = modOffset(i, current, N);
|
||||
el.classList.toggle("is-active", off === 0);
|
||||
el.classList.toggle("is-prev", off === -1);
|
||||
el.classList.toggle("is-next", off === 1);
|
||||
el.classList.toggle("is-far-prev", off < -1);
|
||||
el.classList.toggle("is-far-next", off > 1);
|
||||
});
|
||||
|
||||
// active-class swaps on stacks
|
||||
[piNum, piSub, piLoc, factDate, factFmt, factTime, pcLoc, descText].forEach(host => {
|
||||
host.querySelectorAll("span, p").forEach(c => {
|
||||
c.classList.toggle("is-active", parseInt(c.dataset.idx, 10) === current);
|
||||
});
|
||||
});
|
||||
|
||||
// dial rotation — Pavilion base 135° + step per slide
|
||||
const step = 360 / N;
|
||||
dial.style.setProperty("--dial-rotation", `${135 + current * step}deg`);
|
||||
|
||||
// counter + caption
|
||||
cur.textContent = String(current + 1).padStart(2, "0");
|
||||
pcFrame.textContent = `FRAME ${String(current + 1).padStart(2,"0")} / ${String(N).padStart(2,"0")}`;
|
||||
|
||||
// progress
|
||||
progEls.forEach((el, i) => {
|
||||
el.classList.toggle("active", i === current);
|
||||
el.classList.toggle("passed", i < current);
|
||||
});
|
||||
}
|
||||
|
||||
function go(delta) {
|
||||
if (isAnimating) return;
|
||||
isAnimating = true;
|
||||
current = (current + delta + N) % N;
|
||||
render();
|
||||
setTimeout(() => { isAnimating = false; }, 380);
|
||||
}
|
||||
function jump(i) {
|
||||
if (isAnimating || i === current) return;
|
||||
isAnimating = true;
|
||||
current = ((i % N) + N) % N;
|
||||
render();
|
||||
setTimeout(() => { isAnimating = false; }, 380);
|
||||
}
|
||||
|
||||
/* ===== controls ===== */
|
||||
document.getElementById("prev").addEventListener("click", () => go(-1));
|
||||
document.getElementById("next").addEventListener("click", () => go(1));
|
||||
|
||||
track.addEventListener("click", e => {
|
||||
const t = e.target.closest(".title");
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
jump(parseInt(t.dataset.idx, 10));
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", e => {
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); go(1); }
|
||||
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); go(-1); }
|
||||
else if (e.key === "Home") jump(0);
|
||||
else if (e.key === "End") jump(N - 1);
|
||||
else if (/^[1-9]$/.test(e.key)) jump(parseInt(e.key, 10) - 1);
|
||||
else if (e.key === "0") jump(9);
|
||||
});
|
||||
|
||||
/* ===== wheel ===== */
|
||||
let wheelLock = false;
|
||||
const onWheel = e => {
|
||||
e.preventDefault();
|
||||
if (wheelLock) return;
|
||||
const d = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
||||
if (Math.abs(d) < 8) return;
|
||||
go(d > 0 ? 1 : -1);
|
||||
wheelLock = true;
|
||||
setTimeout(() => { wheelLock = false; }, 480);
|
||||
};
|
||||
document.getElementById("stage").addEventListener("wheel", onWheel, { passive: false });
|
||||
document.querySelector(".info").addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
/* ===== drag / swipe ===== */
|
||||
const stage = document.getElementById("stage");
|
||||
let pointer = null;
|
||||
stage.addEventListener("pointerdown", e => {
|
||||
pointer = { x: e.clientX, y: e.clientY };
|
||||
stage.classList.add("dragging");
|
||||
stage.setPointerCapture(e.pointerId);
|
||||
});
|
||||
stage.addEventListener("pointermove", e => {
|
||||
if (!pointer) return;
|
||||
const dx = e.clientX - pointer.x;
|
||||
const dy = e.clientY - pointer.y;
|
||||
if (Math.abs(dx) > 60 || Math.abs(dy) > 60) {
|
||||
const d = Math.abs(dx) > Math.abs(dy) ? dx : dy;
|
||||
go(d < 0 ? 1 : -1);
|
||||
pointer = null;
|
||||
stage.classList.remove("dragging");
|
||||
}
|
||||
});
|
||||
stage.addEventListener("pointerup", () => { pointer = null; stage.classList.remove("dragging"); });
|
||||
stage.addEventListener("pointercancel", () => { pointer = null; stage.classList.remove("dragging"); });
|
||||
|
||||
/* ===== minor: menu / CTA feedback ===== */
|
||||
document.querySelectorAll(".menu a, .menu button, .description > a").forEach(el => {
|
||||
el.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
if (el.id === "cta") { go(1); return; }
|
||||
});
|
||||
});
|
||||
|
||||
/* ===== initial render ===== */
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user