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
1407 lines
57 KiB
HTML
Executable File
1407 lines
57 KiB
HTML
Executable File
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>虚白 — 设计工作室 · Index 2026</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=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Serif+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
|
||
<style>
|
||
:root{
|
||
--bg: #0d0c0b;
|
||
--ink: #f1ece2;
|
||
--ink-soft: #7a7268;
|
||
--ink-mid: #a8a094;
|
||
--line: #2a2622;
|
||
--line-soft: #1a1816;
|
||
--pill: #f1ece2;
|
||
--pill-ink: #0d0c0b;
|
||
}
|
||
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
html,body{
|
||
width:100%;height:100%;
|
||
overflow:hidden;
|
||
background:var(--bg);
|
||
color:var(--ink);
|
||
font-family:"Inter","PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
|
||
font-size:13px;
|
||
letter-spacing:.01em;
|
||
-webkit-font-smoothing:antialiased;
|
||
user-select:none;
|
||
}
|
||
body{ cursor: grab; }
|
||
body.dragging{ cursor: grabbing; }
|
||
|
||
/* —— 顶栏 —— */
|
||
.topbar{
|
||
position:fixed;top:0;left:0;right:0;
|
||
display:flex; justify-content:space-between; align-items:flex-start;
|
||
padding:22px 26px 16px;
|
||
z-index:150;
|
||
pointer-events:none;
|
||
font-family:"JetBrains Mono","Noto Serif SC", monospace;
|
||
font-size:11px;
|
||
letter-spacing:.03em;
|
||
color:var(--ink);
|
||
transition: opacity .3s ease;
|
||
}
|
||
body.overlay-open .topbar{ opacity:0; pointer-events:none; }
|
||
body.overlay-open .topbar > *{ pointer-events:none; }
|
||
.topbar::before{
|
||
content:""; position:absolute; left:0;right:0;top:0;height:120px;
|
||
background:linear-gradient(to bottom, rgba(13,12,11,.7), rgba(13,12,11,0));
|
||
pointer-events:none; z-index:-1;
|
||
}
|
||
.topbar > *{ pointer-events:auto; }
|
||
|
||
.brand-block{ display:flex; align-items:center; gap:18px; }
|
||
.logo{
|
||
width:48px;height:48px;
|
||
background:var(--pill);
|
||
display:grid; place-items:center;
|
||
border-radius:6px;
|
||
cursor:pointer;
|
||
overflow:hidden;
|
||
transition: transform .25s ease, box-shadow .25s ease;
|
||
box-shadow:0 4px 14px -6px rgba(0,0,0,.55);
|
||
}
|
||
.logo:hover{ transform: rotate(-3deg) scale(1.05); }
|
||
.logo svg{ display:block; width:38px; height:38px; }
|
||
.sound-toggle{
|
||
color:var(--ink-soft);
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:13px;
|
||
cursor:pointer;
|
||
}
|
||
.sound-toggle:hover{ color:var(--ink); }
|
||
|
||
.tagline{
|
||
max-width:340px;
|
||
color:var(--ink);
|
||
line-height:1.55;
|
||
font-family:"Noto Serif SC","PingFang SC", serif;
|
||
font-size:12.5px;
|
||
font-weight:400;
|
||
padding-top:6px;
|
||
}
|
||
|
||
.col-meta, .col-times{
|
||
line-height:1.7;
|
||
color:var(--ink);
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:12px;
|
||
padding-top:8px;
|
||
}
|
||
.col-times{ font-family:"JetBrains Mono", monospace; font-size:11px; }
|
||
.col-meta .row{ display:flex; align-items:center; gap:8px; }
|
||
.loc-dot{
|
||
display:inline-block;width:7px;height:7px;border-radius:50%;
|
||
}
|
||
.loc-dot.live{ background:var(--ink); box-shadow:0 0 8px rgba(241,236,226,.7); }
|
||
.loc-dot.night{ background:transparent; border:1px solid var(--ink-soft); }
|
||
|
||
.cta{
|
||
background:var(--pill); color:var(--pill-ink);
|
||
border:none; border-radius:999px;
|
||
padding:14px 24px;
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:14px; font-weight:500;
|
||
cursor:pointer;
|
||
transition: transform .25s, opacity .2s;
|
||
box-shadow:0 6px 18px -8px rgba(0,0,0,.5);
|
||
}
|
||
.cta:hover{ transform:scale(1.03); }
|
||
|
||
/* —— 底栏 —— */
|
||
.bottombar{
|
||
position:fixed; bottom:22px; left:26px; right:26px;
|
||
display:flex; justify-content:center; align-items:center;
|
||
z-index:150;
|
||
pointer-events:none;
|
||
}
|
||
.bottombar .filter-pill{
|
||
position:absolute; right:0; bottom:0;
|
||
}
|
||
.bottombar::before{
|
||
content:""; position:fixed; left:0;right:0;bottom:0;height:120px;
|
||
background:linear-gradient(to top, rgba(13,12,11,.6), rgba(13,12,11,0));
|
||
pointer-events:none; z-index:-1;
|
||
}
|
||
.bottombar > *{ pointer-events:auto; }
|
||
|
||
.icon-group, .tabs, .filter-pill{
|
||
background:var(--pill);
|
||
border-radius:999px;
|
||
padding:6px;
|
||
display:flex; gap:2px;
|
||
box-shadow:0 6px 18px -8px rgba(0,0,0,.5);
|
||
pointer-events:auto;
|
||
}
|
||
.icon-btn{
|
||
background:transparent; border:none;
|
||
width:36px; height:36px;
|
||
border-radius:999px;
|
||
display:grid; place-items:center;
|
||
cursor:pointer;
|
||
color:var(--pill-ink);
|
||
opacity:.45;
|
||
pointer-events:auto;
|
||
transition: opacity .2s, background .2s, transform .2s;
|
||
}
|
||
.icon-btn:hover{ opacity:1; background:rgba(13,12,11,.10); transform:scale(1.06); }
|
||
.icon-btn.active{ opacity:1; background:rgba(13,12,11,.12); }
|
||
|
||
.tab{
|
||
background:transparent; border:none;
|
||
padding:10px 26px;
|
||
border-radius:999px;
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:14px; font-weight:500;
|
||
color:rgba(13,12,11,.5);
|
||
cursor:pointer;
|
||
pointer-events:auto;
|
||
transition: background .2s, color .2s;
|
||
}
|
||
.tab:hover{ color:var(--pill-ink); background:rgba(13,12,11,.06); }
|
||
.tab.active{
|
||
background:#fff; color:var(--pill-ink);
|
||
box-shadow:0 1px 4px rgba(0,0,0,.08);
|
||
}
|
||
|
||
.filter-pill{
|
||
padding:14px 26px;
|
||
border:none;
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:14px; font-weight:500;
|
||
color:var(--pill-ink);
|
||
cursor:pointer;
|
||
align-items:center; gap:8px;
|
||
}
|
||
.filter-pill:hover{ opacity:.9; }
|
||
|
||
/* —— 视口 / 球面(经纬网)—— */
|
||
.viewport{
|
||
position:fixed; inset:0; overflow:hidden; z-index:10;
|
||
perspective: 1500px;
|
||
perspective-origin: 50% 50%;
|
||
}
|
||
/* 边缘虚化 vignette */
|
||
.viewport::after{
|
||
content:""; position:fixed; inset:0;
|
||
pointer-events:none; z-index:50;
|
||
background:
|
||
radial-gradient(ellipse 80% 70% at center,
|
||
transparent 40%,
|
||
rgba(13,12,11,0.45) 76%,
|
||
rgba(13,12,11,0.94) 100%);
|
||
}
|
||
/* 整个球面:定位到屏幕中心,所有 cell 都挂在它上面 */
|
||
.sphere{
|
||
position:absolute;
|
||
left:50%; top:50%;
|
||
width:0; height:0;
|
||
transform-style: preserve-3d;
|
||
will-change: transform;
|
||
}
|
||
|
||
/* —— 作品格子(正方形,跟相邻 cell 在球面上共享边)—— */
|
||
.work{
|
||
position:absolute;
|
||
left:0; top:0;
|
||
width: var(--cell, 320px);
|
||
height: var(--cell, 320px);
|
||
margin-left: calc(var(--cell, 320px) * -0.5);
|
||
margin-top: calc(var(--cell, 320px) * -0.5);
|
||
transform-style: preserve-3d;
|
||
will-change: transform;
|
||
cursor: pointer;
|
||
backface-visibility: hidden;
|
||
}
|
||
/* frame 填满整个 cell — 相邻 cell 的图片直接接触 */
|
||
.frame{
|
||
position:absolute; inset:0;
|
||
overflow:hidden;
|
||
background:#1a1816;
|
||
box-shadow:
|
||
inset 0 0 0 1px rgba(255,255,255,.04);
|
||
transition: filter .25s ease;
|
||
}
|
||
.work:hover .frame{ filter: brightness(1.08); }
|
||
.frame img{
|
||
display:block; width:100%; height:100%;
|
||
object-fit:cover;
|
||
pointer-events:none;
|
||
-webkit-user-drag:none;
|
||
}
|
||
.frame .num{
|
||
position:absolute; top:14px; left:18px;
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:10px; letter-spacing:.12em;
|
||
color:rgba(241,236,226,.85);
|
||
background:rgba(13,12,11,.4);
|
||
padding:3px 8px;
|
||
border-radius:3px;
|
||
backdrop-filter: blur(6px);
|
||
z-index:6;
|
||
}
|
||
/* cell-head / cell-foot 浮在 frame 顶部和底部,带渐变保证文字可读 */
|
||
.cell-head, .cell-foot{
|
||
position:absolute; left:0; right:0;
|
||
display:flex; justify-content:space-between; align-items:center;
|
||
padding:14px 20px;
|
||
z-index:5;
|
||
pointer-events:none;
|
||
height:auto;
|
||
}
|
||
.cell-head{
|
||
top:0;
|
||
background: linear-gradient(to bottom, rgba(13,12,11,.55), rgba(13,12,11,0));
|
||
}
|
||
.cell-foot{
|
||
bottom:0;
|
||
background: linear-gradient(to top, rgba(13,12,11,.65), rgba(13,12,11,0));
|
||
}
|
||
.cell-head .brand{
|
||
color:rgba(241,236,226,.92);
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:10px;
|
||
letter-spacing:.08em;
|
||
text-transform:uppercase;
|
||
text-shadow: 0 1px 4px rgba(0,0,0,.6);
|
||
}
|
||
.cell-head .proj{
|
||
color:#fff;
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:14px;
|
||
font-weight:500;
|
||
letter-spacing:.04em;
|
||
text-shadow: 0 1px 6px rgba(0,0,0,.7);
|
||
}
|
||
.cell-foot .year{
|
||
color:#fff;
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:10.5px;
|
||
letter-spacing:.06em;
|
||
text-shadow: 0 1px 4px rgba(0,0,0,.6);
|
||
}
|
||
.cell-foot .tags{ display:flex; gap:4px; }
|
||
.cell-foot .tags em{
|
||
font-style:normal;
|
||
display:inline-block;
|
||
border:1px solid rgba(241,236,226,.32);
|
||
border-radius:999px;
|
||
padding:2px 9px;
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:9px; letter-spacing:.12em;
|
||
color:rgba(241,236,226,.92);
|
||
text-transform:uppercase;
|
||
background: rgba(13,12,11,.32);
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
/* HUD */
|
||
.hud{
|
||
position:fixed; left:26px; bottom:80px;
|
||
z-index:55;
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:9.5px; color:var(--ink-soft);
|
||
letter-spacing:.04em; line-height:1.7;
|
||
pointer-events:none;
|
||
text-transform:uppercase;
|
||
}
|
||
.hud span{ color:var(--ink); }
|
||
|
||
.tile{ animation: fadeIn .55s cubic-bezier(.2,.8,.2,1) both; }
|
||
@keyframes fadeIn{ from{opacity:0} to{opacity:1} }
|
||
|
||
.splash{
|
||
position:fixed; inset:0; z-index:200;
|
||
background:var(--bg);
|
||
transition: opacity .6s, visibility .6s;
|
||
}
|
||
.splash.hide{ opacity:0; visibility:hidden; }
|
||
|
||
/* —— 二级页面 / Overlay —— */
|
||
.overlay{
|
||
position:fixed; inset:0;
|
||
background:var(--bg);
|
||
z-index:120;
|
||
overflow-y:auto;
|
||
overflow-x:hidden;
|
||
opacity:0;
|
||
visibility:hidden;
|
||
transition: opacity .35s ease, visibility .35s ease;
|
||
cursor:auto;
|
||
}
|
||
.overlay.show{ opacity:1; visibility:visible; }
|
||
|
||
.overlay-close{
|
||
position:fixed; top:22px; right:26px;
|
||
background:var(--pill); color:var(--pill-ink);
|
||
border:none; border-radius:999px;
|
||
padding:12px 20px;
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:13px; font-weight:500;
|
||
cursor:pointer;
|
||
z-index:160;
|
||
box-shadow:0 6px 18px -8px rgba(0,0,0,.5);
|
||
display:flex; align-items:center; gap:8px;
|
||
transition:transform .2s;
|
||
}
|
||
.overlay-close:hover{ transform:scale(1.04); }
|
||
|
||
/* Detail page */
|
||
.detail{ padding: 80px 60px 120px; max-width: 1400px; margin: 0 auto; }
|
||
.detail-num{
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:11px; letter-spacing:.12em;
|
||
color:var(--ink-soft);
|
||
text-transform:uppercase;
|
||
margin-bottom: 14px;
|
||
}
|
||
.detail-brand{
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:12px; letter-spacing:.1em;
|
||
color:var(--ink-soft);
|
||
text-transform:uppercase;
|
||
margin-bottom: 18px;
|
||
}
|
||
.detail-title{
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:72px; line-height:1.05; font-weight:600;
|
||
color:var(--ink);
|
||
letter-spacing:.01em;
|
||
margin-bottom: 24px;
|
||
}
|
||
.detail-tags{
|
||
display:flex; gap:6px; flex-wrap:wrap;
|
||
margin-bottom:60px;
|
||
}
|
||
.detail-tags em{
|
||
font-style:normal;
|
||
border:1px solid var(--line);
|
||
border-radius:999px;
|
||
padding:5px 14px;
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:10px; letter-spacing:.12em;
|
||
color:var(--ink-soft);
|
||
text-transform:uppercase;
|
||
}
|
||
.detail-hero{
|
||
width:100%;
|
||
aspect-ratio: 16 / 10;
|
||
background:#1a1816;
|
||
overflow:hidden;
|
||
margin-bottom:80px;
|
||
}
|
||
.detail-hero img{ display:block;width:100%;height:100%;object-fit:cover; }
|
||
.detail-body{
|
||
display:grid;
|
||
grid-template-columns: 1.6fr 1fr;
|
||
gap: 80px;
|
||
margin-bottom:100px;
|
||
}
|
||
.detail-desc{
|
||
font-family:"Noto Serif SC","PingFang SC", serif;
|
||
font-size:17px; line-height:1.85;
|
||
color:var(--ink);
|
||
font-weight:400;
|
||
white-space:pre-line;
|
||
}
|
||
.detail-spec{
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:11px; letter-spacing:.06em;
|
||
color:var(--ink-soft);
|
||
text-transform:uppercase;
|
||
line-height:1.85;
|
||
}
|
||
.detail-spec .row{
|
||
display:flex; justify-content:space-between;
|
||
padding: 14px 0;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.detail-spec .row .v{ color:var(--ink); }
|
||
.detail-gallery{
|
||
display:grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 20px;
|
||
margin-bottom: 100px;
|
||
}
|
||
.detail-gallery div{
|
||
background:#1a1816;
|
||
aspect-ratio: 4 / 3;
|
||
overflow:hidden;
|
||
}
|
||
.detail-gallery img{ display:block;width:100%;height:100%;object-fit:cover; }
|
||
.detail-nav{
|
||
display:flex; justify-content:space-between;
|
||
border-top: 1px solid var(--line);
|
||
padding-top: 30px;
|
||
}
|
||
.detail-nav a{
|
||
color:var(--ink-soft);
|
||
text-decoration:none;
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:14px;
|
||
display:flex; flex-direction:column; gap:4px;
|
||
cursor:pointer;
|
||
}
|
||
.detail-nav a:hover{ color:var(--ink); }
|
||
.detail-nav .label{
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:10px; letter-spacing:.12em;
|
||
text-transform:uppercase;
|
||
color:var(--ink-soft);
|
||
}
|
||
.detail-nav .next{ text-align:right; align-items:flex-end; }
|
||
|
||
/* Generic info overlay (about / cv / contact / filter) */
|
||
.info-pane{ padding:100px 60px 120px; max-width: 1100px; margin:0 auto; }
|
||
.info-eyebrow{
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:11px; letter-spacing:.14em;
|
||
color:var(--ink-soft);
|
||
text-transform:uppercase;
|
||
margin-bottom:18px;
|
||
}
|
||
.info-title{
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:80px; line-height:1; font-weight:600;
|
||
color:var(--ink);
|
||
margin-bottom:60px;
|
||
letter-spacing:.02em;
|
||
}
|
||
.info-body{
|
||
font-family:"Noto Serif SC","PingFang SC", serif;
|
||
font-size:18px; line-height:1.85;
|
||
color:var(--ink);
|
||
max-width:760px;
|
||
margin-bottom:80px;
|
||
white-space:pre-line;
|
||
}
|
||
.info-section{ margin-bottom:60px; }
|
||
.info-section h3{
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:11px; letter-spacing:.14em;
|
||
color:var(--ink-soft);
|
||
text-transform:uppercase;
|
||
margin-bottom:20px;
|
||
font-weight:500;
|
||
}
|
||
.info-list{
|
||
display:grid;
|
||
grid-template-columns: 140px 1fr;
|
||
gap: 8px 30px;
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:15px; line-height:1.8;
|
||
color:var(--ink);
|
||
}
|
||
.info-list .y{
|
||
color:var(--ink-soft);
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:12px; letter-spacing:.06em;
|
||
}
|
||
.contact-grid{
|
||
display:grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 40px 60px;
|
||
}
|
||
.contact-cell h4{
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:10.5px; letter-spacing:.14em;
|
||
color:var(--ink-soft);
|
||
text-transform:uppercase;
|
||
font-weight:500;
|
||
margin-bottom:10px;
|
||
}
|
||
.contact-cell p{
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:18px; line-height:1.6;
|
||
color:var(--ink);
|
||
}
|
||
.contact-cell a{ color:var(--ink); text-decoration:underline; text-decoration-color: var(--line); text-underline-offset: 4px; }
|
||
.contact-cell a:hover{ text-decoration-color: var(--ink); }
|
||
|
||
/* Filter pane */
|
||
.filter-grid{
|
||
display:grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap:50px;
|
||
}
|
||
.filter-grid h4{
|
||
font-family:"JetBrains Mono", monospace;
|
||
font-size:10.5px; letter-spacing:.14em;
|
||
color:var(--ink-soft);
|
||
text-transform:uppercase;
|
||
font-weight:500;
|
||
margin-bottom:18px;
|
||
}
|
||
.filter-grid label{
|
||
display:flex; align-items:center; gap:10px;
|
||
font-family:"Noto Serif SC", serif;
|
||
font-size:14px;
|
||
color:var(--ink);
|
||
padding: 7px 0;
|
||
cursor:pointer;
|
||
}
|
||
.filter-grid label input{ accent-color: var(--ink); }
|
||
|
||
@media (max-width: 900px){
|
||
.tagline{ display:none; }
|
||
.col-meta, .col-times{ font-size:10.5px; }
|
||
.detail-title{ font-size:48px; }
|
||
.detail-body{ grid-template-columns:1fr; gap:40px; }
|
||
.info-title{ font-size:48px; }
|
||
.filter-grid, .contact-grid{ grid-template-columns:1fr; }
|
||
.detail{ padding:80px 28px 100px; }
|
||
.info-pane{ padding:100px 28px 120px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="splash" id="splash"></div>
|
||
|
||
<header class="topbar">
|
||
<div class="brand-block">
|
||
<div class="logo" data-nav="home" title="返回首页">
|
||
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||
<defs>
|
||
<filter id="ink" x="-15%" y="-15%" width="130%" height="130%">
|
||
<feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="2" seed="6" result="noise"/>
|
||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="1.8"/>
|
||
</filter>
|
||
</defs>
|
||
<g filter="url(#ink)" fill="#1d3a8c" stroke="#1d3a8c">
|
||
<!-- 略微歪斜的方框(手刻印章) -->
|
||
<path d="M 8 9 L 72 7 L 73 71 L 9 72 Z" fill="none" stroke-width="3" stroke-linejoin="round"/>
|
||
<!-- 三个抽象立人 -->
|
||
<g stroke="none">
|
||
<!-- person 1 -->
|
||
<circle cx="22" cy="26" r="3.2"/>
|
||
<path d="M 22 30 L 22 50 M 22 35 L 16 42 M 22 35 L 28 42 M 22 50 L 17 62 M 22 50 L 27 62" stroke-width="2.6" stroke="#1d3a8c" fill="none" stroke-linecap="round"/>
|
||
<!-- person 2 -->
|
||
<circle cx="40" cy="24" r="3.2"/>
|
||
<path d="M 40 28 L 40 50 M 40 33 L 33 28 M 40 33 L 47 28 M 40 50 L 35 62 M 40 50 L 45 62" stroke-width="2.6" stroke="#1d3a8c" fill="none" stroke-linecap="round"/>
|
||
<!-- person 3 -->
|
||
<circle cx="58" cy="26" r="3.2"/>
|
||
<path d="M 58 30 L 58 50 M 58 35 L 52 42 M 58 35 L 64 42 M 58 50 L 53 62 M 58 50 L 63 62" stroke-width="2.6" stroke="#1d3a8c" fill="none" stroke-linecap="round"/>
|
||
</g>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
<span class="sound-toggle">声音 [关闭]</span>
|
||
</div>
|
||
|
||
<div class="tagline">
|
||
虚白是一家以材料与场景为方法的中文设计工作室,<br>
|
||
致力于在品牌、视觉与空间之间寻找静止的位置。
|
||
</div>
|
||
|
||
<div class="col-meta">
|
||
<div class="row"><span class="loc-dot live"></span>中国 · 上海</div>
|
||
<div class="row"><span class="loc-dot night"></span>日本 · 京都</div>
|
||
</div>
|
||
|
||
<div class="col-times">
|
||
<div>北京时间 UTC+8</div>
|
||
<div id="time1">17:30</div>
|
||
<div id="time2">18:30 JST</div>
|
||
</div>
|
||
|
||
<button class="cta" data-nav="contact">我们来聊聊吧。</button>
|
||
</header>
|
||
|
||
<!-- 球面:所有作品 cell 都贴在这个球面上,相邻 cell 沿经纬线共享边 -->
|
||
<div class="viewport" id="viewport">
|
||
<div class="sphere" id="sphere"></div>
|
||
</div>
|
||
|
||
<div class="hud">
|
||
POS <span id="pos">0°, 0°</span><br>
|
||
ZOOM <span id="zoom">1.00×</span> · 拖拽旋转球面 / 滚轮缩放 / <span>0</span> 回中心 / 点击查看
|
||
</div>
|
||
|
||
<div class="bottombar">
|
||
<div class="tabs">
|
||
<button class="tab active" data-nav="home">作品</button>
|
||
<button class="tab" data-nav="about">关于</button>
|
||
<button class="tab" data-nav="cv">履历</button>
|
||
</div>
|
||
|
||
<button class="filter-pill" data-nav="filter">
|
||
<svg width="11" height="11" viewBox="0 0 11 11" style="opacity:.55"><path d="M0.5 1.5h10M2 5.5h7M3.5 9.5h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||
筛选
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ============ 二级页面 ============ -->
|
||
<div class="overlay" id="overlay-detail">
|
||
<button class="overlay-close" data-nav="home">← 返回</button>
|
||
<div class="detail" id="detail-content"></div>
|
||
</div>
|
||
|
||
<div class="overlay" id="overlay-about">
|
||
<button class="overlay-close" data-nav="home">← 返回</button>
|
||
<div class="info-pane">
|
||
<div class="info-eyebrow">关于 · About the Studio</div>
|
||
<h1 class="info-title">在材料与场景<br>之间,构造静止。</h1>
|
||
<div class="info-body">虚白成立于 2017 年的上海,是一家由六位设计师组成的小型工作室。我们关心一件作品在最终落地之前,材料、场景与时间如何共同塑造它的样貌——而非追逐某种风格的表层。
|
||
|
||
我们服务过博物馆、出版机构、艺术空间、独立品牌与艺术家个体。无论项目规模与媒介如何变化,我们倾向于先观察、再回应;先减法、再装饰。
|
||
|
||
从 2020 年起,我们在京都设立了第二个工作站,与一位驻地匠人合作完成所有的物料与原型工作。</div>
|
||
|
||
<div class="info-section">
|
||
<h3>能力领域</h3>
|
||
<div class="info-list">
|
||
<span class="y">01</span><span>品牌系统 / 视觉识别 / 字体定制</span>
|
||
<span class="y">02</span><span>编辑设计 / 出版物 / 印刷品</span>
|
||
<span class="y">03</span><span>数字产品 / 网站 / 互动体验</span>
|
||
<span class="y">04</span><span>空间装置 / 展览 / 临时建筑</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-section">
|
||
<h3>团队成员</h3>
|
||
<div class="info-list">
|
||
<span class="y">主理人</span><span>沈白 · 创意总监</span>
|
||
<span class="y">设计</span><span>陆青、林一川、何予安</span>
|
||
<span class="y">技术</span><span>周野</span>
|
||
<span class="y">工艺</span><span>松本健(驻京都)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay" id="overlay-cv">
|
||
<button class="overlay-close" data-nav="home">← 返回</button>
|
||
<div class="info-pane">
|
||
<div class="info-eyebrow">履历 · Selected Works</div>
|
||
<h1 class="info-title">2017 — 2026<br>九年的工作。</h1>
|
||
<div class="info-body">下面是我们认为值得被记录的 32 个项目,按照时间倒序排列。完整作品列表与未公开案例,请联系工作室索取作品集 PDF。</div>
|
||
|
||
<div class="info-section">
|
||
<h3>2025</h3>
|
||
<div class="info-list">
|
||
<span class="y">2025</span><span>水镜 — 苏州博物馆 / 装置</span>
|
||
<span class="y">2025</span><span>数字诗集 v.02 — 个人 / Web</span>
|
||
<span class="y">2025</span><span>字非字 — 实验字体 / 独立</span>
|
||
<span class="y">2025</span><span>字图 #047 — 个人 / 创作</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-section">
|
||
<h3>2024</h3>
|
||
<div class="info-list">
|
||
<span class="y">2024</span><span>东方观象台 — 上海天文馆 / 品牌</span>
|
||
<span class="y">2024</span><span>方圆律所 — 方圆所 / 品牌</span>
|
||
<span class="y">2024</span><span>沉影官网 — 沉影空间 / Web</span>
|
||
<span class="y">2024</span><span>白盒子艺廊 — 白盒子 / Web</span>
|
||
<span class="y">2024</span><span>无名之地 — 上海西岸 / 装置</span>
|
||
<span class="y">2024</span><span>静物 · 光谱 — 个展 / 海报</span>
|
||
<span class="y">2024</span><span>声音地形 — 上海当代 / 装置</span>
|
||
<span class="y">2024</span><span>夜半笔记 — 个人 / 速写</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-section">
|
||
<h3>2023</h3>
|
||
<div class="info-list">
|
||
<span class="y">2023</span><span>南山诗刊 — 三联书店 / 编辑</span>
|
||
<span class="y">2023</span><span>明月几时有 — 中央美院 / 互动</span>
|
||
<span class="y">2023</span><span>呼吸的墙 — 北京 798 / 装置</span>
|
||
<span class="y">2023</span><span>时间的褶皱 — 上海双年展 / 海报</span>
|
||
<span class="y">2023</span><span>空亭 — 西塘古镇 / 临时建筑</span>
|
||
<span class="y">2023</span><span>青木茶寮 — 青木茶寮 / 品牌</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-section">
|
||
<h3>2022 及更早</h3>
|
||
<div class="info-list">
|
||
<span class="y">2022</span><span>无声电台 — 独立播客 / 品牌</span>
|
||
<span class="y">2022</span><span>冬日影像志 — 三联生活周刊 / 编辑</span>
|
||
<span class="y">2021</span><span>未发表作品 12 项</span>
|
||
<span class="y">2017</span><span>工作室成立 · 上海</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-section">
|
||
<h3>展览与奖项</h3>
|
||
<div class="info-list">
|
||
<span class="y">2025</span><span>东京字体艺术指导俱乐部年鉴 · 入选</span>
|
||
<span class="y">2024</span><span>上海平面设计大展 · 银奖</span>
|
||
<span class="y">2024</span><span>D&AD Wood Pencil · 编辑设计</span>
|
||
<span class="y">2023</span><span>北京设计周 · 客座工作坊</span>
|
||
<span class="y">2022</span><span>东京 TDC 字形入围</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay" id="overlay-contact">
|
||
<button class="overlay-close" data-nav="home">← 返回</button>
|
||
<div class="info-pane">
|
||
<div class="info-eyebrow">联系 · Get in touch</div>
|
||
<h1 class="info-title">我们来<br>聊聊吧。</h1>
|
||
<div class="info-body">如果你有一个尚不清晰、但值得认真对待的想法,写信给我们。
|
||
我们一年只接 8—10 个项目,回信平均需要 3 个工作日。</div>
|
||
|
||
<div class="contact-grid">
|
||
<div class="contact-cell">
|
||
<h4>新项目咨询</h4>
|
||
<p><a href="mailto:hello@xubai.studio">hello@xubai.studio</a></p>
|
||
</div>
|
||
<div class="contact-cell">
|
||
<h4>媒体 · 写作</h4>
|
||
<p><a href="mailto:press@xubai.studio">press@xubai.studio</a></p>
|
||
</div>
|
||
<div class="contact-cell">
|
||
<h4>上海工作室</h4>
|
||
<p>中国上海市黄浦区建国中路 10 号<br>3 号楼 4F<br>邮编 200025</p>
|
||
</div>
|
||
<div class="contact-cell">
|
||
<h4>京都驻地</h4>
|
||
<p>日本京都市左京区净土寺西田町<br>3-12 二楼</p>
|
||
</div>
|
||
<div class="contact-cell">
|
||
<h4>社交</h4>
|
||
<p><a href="#">Instagram @xubai.studio</a><br><a href="#">微信公众号 · 虚白工作室</a></p>
|
||
</div>
|
||
<div class="contact-cell">
|
||
<h4>招聘</h4>
|
||
<p>2026 春季招聘 1 位品牌设计师 · 1 位前端工程师。简历投至 <a href="mailto:join@xubai.studio">join@xubai.studio</a></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay" id="overlay-filter">
|
||
<button class="overlay-close" data-nav="home">← 返回</button>
|
||
<div class="info-pane">
|
||
<div class="info-eyebrow">筛选 · Filter</div>
|
||
<h1 class="info-title">查找作品。</h1>
|
||
|
||
<div class="filter-grid">
|
||
<div>
|
||
<h4>类别</h4>
|
||
<label><input type="checkbox" checked> 品牌设计</label>
|
||
<label><input type="checkbox" checked> 编辑 / 印刷</label>
|
||
<label><input type="checkbox" checked> 网页 / 互动</label>
|
||
<label><input type="checkbox" checked> 装置 / 空间</label>
|
||
<label><input type="checkbox" checked> 字体 / 实验</label>
|
||
<label><input type="checkbox" checked> 海报 / 视觉</label>
|
||
</div>
|
||
<div>
|
||
<h4>年份</h4>
|
||
<label><input type="checkbox" checked> 2025</label>
|
||
<label><input type="checkbox" checked> 2024</label>
|
||
<label><input type="checkbox" checked> 2023</label>
|
||
<label><input type="checkbox" checked> 2022</label>
|
||
<label><input type="checkbox"> 2021 及更早</label>
|
||
</div>
|
||
<div>
|
||
<h4>客户类型</h4>
|
||
<label><input type="checkbox" checked> 文化机构</label>
|
||
<label><input type="checkbox" checked> 商业品牌</label>
|
||
<label><input type="checkbox" checked> 独立 / 个人</label>
|
||
<label><input type="checkbox" checked> 艺术家合作</label>
|
||
<label><input type="checkbox"> 自发项目</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ====== 作品数据 ======
|
||
const WORKS = [
|
||
{
|
||
id:'01', brand:'上海天文馆', title:'东方观象台', year:2024,
|
||
tags:['Identity','Wayfinding','System'],
|
||
img:'images/p-01-color-macro.jpg',
|
||
hero:'images/p-02-typography.jpg',
|
||
g1:'images/p-03-packaging.jpg',
|
||
g2:'images/p-04-paint-stroke.jpg',
|
||
desc:'为新落成的天文馆设计的视觉识别系统。基于天体运行的几何节律,构建一套动态生长的标识与导视语言。',
|
||
long:'整套视觉系统从天体运行的几何节律出发,构建出一套可生长的标识与导视语言。我们设计了 12 套围绕节气的动态标识,以及覆盖整馆的模块化导视体系,让观众在馆内的每一次抬头都能感知到时间的流动。\n\n项目跨越了 11 个月,包括标识研发、字体定制、空间导视、网站、印刷品与数字物料。最终的视觉系统已在全馆 18,000 平方米的空间内全面落地。',
|
||
spec:[['Client','上海天文馆'],['Year','2024'],['Scope','品牌系统 · 导视'],['Duration','11 个月']]
|
||
},
|
||
{
|
||
id:'02', brand:'青木茶寮', title:'青木茶寮 VI', year:2023,
|
||
tags:['Identity','Packaging'],
|
||
img:'images/p-05-brand-flatlay.jpg',
|
||
hero:'images/p-06-studio-portrait.jpg',
|
||
g1:'images/p-07-gradient.jpg',
|
||
g2:'images/p-08-editorial.jpg',
|
||
desc:'京都风格茶寮的整套视觉。从手写题字到木刻包装,回到工艺与时间本身。',
|
||
long:'青木茶寮是京都左京区的一家小型茶馆。我们用六个月时间,与店主一同重新整理了它的视觉系统:标识由当地书法家书写后木刻;包装统一回到未漂白的素纸;菜单印刷采用活字工艺。\n\n品牌的核心不在新意,而在于一种克制的、可持续被使用的安静感。',
|
||
spec:[['Client','青木茶寮'],['Year','2023'],['Scope','标识 · 包装 · 物料'],['Location','日本京都']]
|
||
},
|
||
{
|
||
id:'03', brand:'独立播客', title:'无声电台', year:2022,
|
||
tags:['Identity','Sound','Cover'],
|
||
img:'images/p-09-ceramic.jpg',
|
||
hero:'images/p-10-urban.jpg',
|
||
g1:'images/p-11-ink-paper.jpg',
|
||
g2:'images/p-12-botanical.jpg',
|
||
desc:'一档关注城市深夜声音的独立播客。VI 与 60 多期节目封面均由工作室设计。',
|
||
long:'无声电台是一档由两位编辑制作的城市夜间声音播客。从 2022 年至今已发布 64 期。\n\n我们为节目设计了一套可生长的封面系统:每期封面都基于一段录音波形,自动派生出一个抽象图形。播客的标识与名片亦由同一套规则生成。',
|
||
spec:[['Client','独立播客'],['Year','2022'],['Scope','VI · 64 期封面'],['Format','Spotify · Apple']]
|
||
},
|
||
{
|
||
id:'04', brand:'西岸艺术中心', title:'无名之地', year:2024,
|
||
tags:['Installation','Light'],
|
||
img:'images/p-13-risograph.jpg',
|
||
hero:'images/p-14-gallery-wall.jpg',
|
||
g1:'images/p-15-light-motion.jpg',
|
||
g2:'images/p-16-interior.jpg',
|
||
desc:'沿旧厂房梁柱排布的低强度光带,黄昏后才真正显形,把一段被忽略的空间归还给人的目光。',
|
||
long:'《无名之地》是为上海西岸艺术中心北侧旧厂房做的临时灯光介入。我们沿着 18 米长的钢梁布置了一组低强度暖白色光带,光带在白天几乎不可见,唯有在黄昏后逐渐显形。\n\n作品不指向任何主题——它的目的是让那段被人长期忽略的工业空间,被重新看到一次。展期结束后所有材料被回收。',
|
||
spec:[['Client','西岸艺术中心'],['Year','2024'],['Scope','灯光装置'],['Duration','42 天']]
|
||
},
|
||
{
|
||
id:'05', brand:'个人项目', title:'静物 · 光谱', year:2024,
|
||
tags:['Poster','Exhibition'],
|
||
img:'images/p-17-color-block.jpg',
|
||
hero:'images/p-18-magazine.jpg',
|
||
g1:'images/p-01-color-macro.jpg',
|
||
g2:'images/p-02-typography.jpg',
|
||
desc:'个人展览的二十张海报,记录光在桌面上的不同时刻。',
|
||
long:'《静物 · 光谱》是 2024 年 9 月在上海一处独立画廊举办的小型海报个展,展出 20 张丝印海报。\n\n每张海报都基于同一张静物照片——一杯水、一片纸、一颗石——只是色温随时间被逐渐分解。这是一次关于"时间是否可以被印刷"的提问。',
|
||
spec:[['Client','个展'],['Year','2024'],['Scope','20 张海报 · 丝印'],['Edition','30 套']]
|
||
},
|
||
{
|
||
id:'06', brand:'三联书店', title:'南山诗刊', year:2023,
|
||
tags:['Editorial','Print','Series'],
|
||
img:'images/p-03-packaging.jpg',
|
||
hero:'images/p-04-paint-stroke.jpg',
|
||
g1:'images/p-05-brand-flatlay.jpg',
|
||
g2:'images/p-06-studio-portrait.jpg',
|
||
desc:'季度诗刊的整体设计。每期围绕一座山展开,文字、图像与白纸之间相互让位。',
|
||
long:'《南山诗刊》是三联书店出版的一本独立诗刊,季度发行,每期约 80 页。\n\n我们设计了刊物的整体识别、版式、印刷工艺。每一期围绕一座中国境内的山展开,封面由一张山的轮廓图与刊名构成。内文极少装饰,主要是诗与白纸之间的呼吸。',
|
||
spec:[['Client','三联书店'],['Year','2023'],['Scope','整刊设计 · 4 期'],['Pages','80 / 期']]
|
||
},
|
||
{
|
||
id:'07', brand:'独立项目', title:'字非字', year:2025,
|
||
tags:['Type','Experiment'],
|
||
img:'images/p-07-gradient.jpg',
|
||
hero:'images/p-08-editorial.jpg',
|
||
g1:'images/p-09-ceramic.jpg',
|
||
g2:'images/p-10-urban.jpg',
|
||
desc:'关于"汉字之外"的字形实验,探索笔画解构后剩下的结构性沉默。',
|
||
long:'《字非字》是工作室 2024–2025 年的一项内部实验,共制作了 286 个字形。\n\n这些字形并不"成字"——它们是汉字结构被解构、抽离、再保留的部分。整套字形以可变字体形式公开发布,供研究使用。',
|
||
spec:[['Client','独立项目'],['Year','2025'],['Scope','286 字形 · 可变字体'],['License','非商用']]
|
||
},
|
||
{
|
||
id:'08', brand:'方圆律师事务所', title:'方圆律所', year:2024,
|
||
tags:['Identity','Legal'],
|
||
img:'images/p-11-ink-paper.jpg',
|
||
hero:'images/p-12-botanical.jpg',
|
||
g1:'images/p-13-risograph.jpg',
|
||
g2:'images/p-14-gallery-wall.jpg',
|
||
desc:'在严肃与可亲之间寻找平衡。"方圆"二字成为整个视觉的几何核心。',
|
||
long:'方圆律师事务所是一家专注于商业并购的中型律所。我们为他们重新设计了品牌系统——以"方"与"圆"两个汉字的几何关系作为视觉核心,覆盖了名片、文书、签约用品、官网与办公空间标识。\n\n核心理念是:让一家律所的品牌既有专业感,也有人在其中。',
|
||
spec:[['Client','方圆所'],['Year','2024'],['Scope','VI · 名片 · 文书系统'],['Coverage','45 项物料']]
|
||
},
|
||
{
|
||
id:'09', brand:'三联生活周刊', title:'冬日影像志', year:2022,
|
||
tags:['Editorial','Photo'],
|
||
img:'images/p-15-light-motion.jpg',
|
||
hero:'images/p-16-interior.jpg',
|
||
g1:'images/p-17-color-block.jpg',
|
||
g2:'images/p-18-magazine.jpg',
|
||
desc:'一本冬季摄影特辑。无装饰的网格与留白节奏承载所有图像的重量。',
|
||
long:'《冬日影像志》是三联生活周刊在 2022 年冬天出版的一本 168 页摄影 mook,收录了 9 位中国摄影师的冬季作品。\n\n我们设计了刊物的版式与印刷规格——所有图像采用三色印刷,以增强冬天特有的灰度层次。版式上几乎不出现任何文字装饰,只让图像与图像之间留白。',
|
||
spec:[['Client','三联生活周刊'],['Year','2022'],['Scope','版式 · 印制规范'],['Pages','168']]
|
||
},
|
||
{
|
||
id:'10', brand:'个人项目', title:'数字诗集 v.02', year:2025,
|
||
tags:['Web','WebGL','Poetry'],
|
||
img:'images/p-01-color-macro.jpg',
|
||
hero:'images/p-02-typography.jpg',
|
||
g1:'images/p-03-packaging.jpg',
|
||
g2:'images/p-04-paint-stroke.jpg',
|
||
desc:'网页诗集的第二版。每一首都是一段可被读者扰动的动态文本。',
|
||
long:'《数字诗集》是工作室自 2022 年开始的实验性出版项目。第二版收录了 24 首当代汉语诗,每一首都由 WebGL 着色器重写为一段可被读者交互的动态文本。\n\n读者可以拖动、悬停、吹动屏幕,文字随之飘散或重组。它是一次关于"诗在浏览器里如何继续被读"的练习。',
|
||
spec:[['Client','个人项目'],['Year','2025'],['Scope','24 首 · WebGL'],['URL','poetry.xubai.studio']]
|
||
},
|
||
{
|
||
id:'11', brand:'中央美术学院', title:'明月几时有', year:2023,
|
||
tags:['Interactive','Narrative'],
|
||
img:'images/p-05-brand-flatlay.jpg',
|
||
hero:'images/p-06-studio-portrait.jpg',
|
||
g1:'images/p-07-gradient.jpg',
|
||
g2:'images/p-08-editorial.jpg',
|
||
desc:'以宋词为脚本的互动叙事实验。鼠标的每一次停顿都改变下一行的语意。',
|
||
long:'《明月几时有》是与中央美术学院实验艺术学院合作的一次互动叙事项目。我们以苏轼的同名宋词为基底,重新解构成 28 个语义单元。\n\n读者通过鼠标在屏幕上的停留时间影响叙事路径——停留越久,下一行的语境越偏向静;越快滑过,越偏向动。每一次阅读都是一次独立的版本。',
|
||
spec:[['Client','中央美院'],['Year','2023'],['Scope','互动叙事 · 毕设'],['Tech','Three.js']]
|
||
},
|
||
{
|
||
id:'12', brand:'沉影空间', title:'沉影 · 官方网站', year:2024,
|
||
tags:['Web','Slow'],
|
||
img:'images/p-09-ceramic.jpg',
|
||
hero:'images/p-10-urban.jpg',
|
||
g1:'images/p-11-ink-paper.jpg',
|
||
g2:'images/p-12-botanical.jpg',
|
||
desc:'影像艺术空间的官方网站。整站围绕慢速滚动展开,让浏览本身成为一种观看。',
|
||
long:'沉影是一家以影像作品为主的当代艺术空间,每年策划 4–6 个展览。我们为他们设计了官方网站——整站不使用任何弹窗、模态、悬停动画。一切只通过滚动展开。\n\n滚动速度被刻意降低 40%,使观众无法快速浏览,必须停下来。这与他们的展览理念是同构的。',
|
||
spec:[['Client','沉影空间'],['Year','2024'],['Scope','整站 · 18 页'],['Tech','Astro · GSAP']]
|
||
},
|
||
{
|
||
id:'13', brand:'白盒子艺廊', title:'白盒子艺廊', year:2024,
|
||
tags:['Web','Gallery','CMS'],
|
||
img:'images/p-13-risograph.jpg',
|
||
hero:'images/p-14-gallery-wall.jpg',
|
||
g1:'images/p-15-light-motion.jpg',
|
||
g2:'images/p-16-interior.jpg',
|
||
desc:'极简画廊网站。模块化 CMS 让画廊主自己管理展览、艺术家与作品。',
|
||
long:'白盒子艺廊是上海一家成立于 2018 年的小型当代艺术空间。我们设计并开发了它的官方网站与后台 CMS。\n\n前台界面几乎只有图像、艺术家姓名与展览年份;后台允许画廊主每周自行更新内容、调整顺序、管理馆藏。',
|
||
spec:[['Client','白盒子'],['Year','2024'],['Scope','整站 · CMS'],['Tech','Next · Sanity']]
|
||
},
|
||
{
|
||
id:'14', brand:'北京 798', title:'呼吸的墙', year:2023,
|
||
tags:['Installation','Textile'],
|
||
img:'images/p-17-color-block.jpg',
|
||
hero:'images/p-18-magazine.jpg',
|
||
g1:'images/p-01-color-macro.jpg',
|
||
g2:'images/p-02-typography.jpg',
|
||
desc:'由温感面料构成的可呼吸墙体。当观众靠近,墙以体温的频率起伏。',
|
||
long:'《呼吸的墙》是 2023 年在北京 798 艺术区一个临时空间内展示的作品。墙体由一种温感面料构成,覆盖了 6×3 米的墙面。\n\n墙后藏有 24 个微型气泵——当红外线感知到观众靠近时,气泵以观众心率的近似频率为墙体充放气。墙开始"呼吸"。',
|
||
spec:[['Client','北京 798'],['Year','2023'],['Scope','装置 · 6m × 3m'],['Sensors','24 ch']]
|
||
},
|
||
{
|
||
id:'15', brand:'上海当代艺术馆', title:'声音地形', year:2024,
|
||
tags:['Sound','Spatial'],
|
||
img:'images/p-03-packaging.jpg',
|
||
hero:'images/p-04-paint-stroke.jpg',
|
||
g1:'images/p-05-brand-flatlay.jpg',
|
||
g2:'images/p-06-studio-portrait.jpg',
|
||
desc:'由 96 个微型扬声器构成的声音地形。每走一步路,都在改变地图。',
|
||
long:'《声音地形》是为上海当代艺术馆 2024 年秋季群展所做的一件 17 分钟的步行式声音装置。\n\n我们在展厅地面下方布设了 96 个独立通道的微型扬声器,每个扬声器播放一段不超过 2 秒的环境录音。观众的脚步触发不同区域的扬声器组合——同一段路在不同的步频下听起来完全不同。',
|
||
spec:[['Client','上海当代'],['Year','2024'],['Scope','96 通道 · 17 min'],['Area','220 m²']]
|
||
},
|
||
{
|
||
id:'16', brand:'苏州博物馆', title:'水镜', year:2025,
|
||
tags:['Sculpture','Water'],
|
||
img:'images/p-07-gradient.jpg',
|
||
hero:'images/p-08-editorial.jpg',
|
||
g1:'images/p-09-ceramic.jpg',
|
||
g2:'images/p-10-urban.jpg',
|
||
desc:'置于苏州博物馆庭院中的镜面水池。倒影成为一种安静的雕塑。',
|
||
long:'《水镜》是受苏州博物馆邀请,为其新落成的庭院设计的一件永久性装置。一块 12 平方米、深仅 5 毫米的镜面水池,被嵌入庭院南端的青石平台中。\n\n池水从一侧极缓慢地循环流入,使水面始终保持镜面状态。倒影所映出的,是博物馆白墙、屋脊与远处一树梅花。',
|
||
spec:[['Client','苏州博物馆'],['Year','2025'],['Scope','永久装置 · 12 m²'],['Material','不锈钢 · 水']]
|
||
},
|
||
{
|
||
id:'17', brand:'上海双年展', title:'时间的褶皱', year:2023,
|
||
tags:['Poster','Biennale'],
|
||
img:'images/p-11-ink-paper.jpg',
|
||
hero:'images/p-12-botanical.jpg',
|
||
g1:'images/p-13-risograph.jpg',
|
||
g2:'images/p-14-gallery-wall.jpg',
|
||
desc:'为上海双年展所做的主视觉系统。一张纸被反复折叠,留下不可逆的地理。',
|
||
long:'《时间的褶皱》是 2023 上海双年展的主视觉与海报系统。我们的方法是字面意义的——把 36 张全开纸反复手工折叠后铺平,扫描后作为视觉的基底。\n\n每一道折痕代表一次时间的介入。最终的主视觉由 6 张被选中的"折痕地图"演变而成,覆盖了海报、画册、导视与衍生品。',
|
||
spec:[['Client','上海双年展'],['Year','2023'],['Scope','主视觉 · 海报系统'],['Posters','12 张']]
|
||
},
|
||
{
|
||
id:'18', brand:'西塘古镇', title:'空亭', year:2023,
|
||
tags:['Architecture','Temporary'],
|
||
img:'images/p-15-light-motion.jpg',
|
||
hero:'images/p-16-interior.jpg',
|
||
g1:'images/p-17-color-block.jpg',
|
||
g2:'images/p-18-magazine.jpg',
|
||
desc:'由 36 根白色钢管搭建的临时亭子。三个月后拆除,只留下一组照片。',
|
||
long:'《空亭》是为西塘古镇 2023 年秋季艺术周设计的一处临时建筑。结构由 36 根白色无缝钢管以最少节点连接而成,形成一个 3.6 米见方的开放亭子,无墙、无顶、无装饰。\n\n它被放置在古镇南面的水边,三个月后被完整拆除。我们刻意没有为它做任何视觉设计或标识——它的价值就在于它存在过的那一段时间。',
|
||
spec:[['Client','西塘古镇艺术周'],['Year','2023'],['Scope','临时建筑'],['Footprint','3.6 × 3.6 m']]
|
||
},
|
||
{
|
||
id:'19', brand:'个人创作', title:'字图 #047', year:2025,
|
||
tags:['Diary','Type'],
|
||
img:'images/p-01-color-macro.jpg',
|
||
hero:'images/p-02-typography.jpg',
|
||
g1:'images/p-03-packaging.jpg',
|
||
g2:'images/p-04-paint-stroke.jpg',
|
||
desc:'每日字图练习的第 047 号。墨与纸之间的对话。',
|
||
long:'《字图》是工作室主理人沈白自 2024 年开始的每日练习,每天一张,用毛笔与宣纸记录一个字或一组笔画。这是 047 号。\n\n练习的目的不是书法,而是观察——观察墨在纸上的扩散与停止,观察手势与呼吸的关系。完整的 365 张已开始计划在 2027 年集结出版。',
|
||
spec:[['Client','个人创作'],['Year','2025'],['Scope','日课 · 第 047 号'],['Material','宣纸 · 墨']]
|
||
},
|
||
{
|
||
id:'20', brand:'个人创作', title:'夜半笔记', year:2024,
|
||
tags:['Sketch','Ink'],
|
||
img:'images/p-05-brand-flatlay.jpg',
|
||
hero:'images/p-06-studio-portrait.jpg',
|
||
g1:'images/p-07-gradient.jpg',
|
||
g2:'images/p-08-editorial.jpg',
|
||
desc:'失眠时的速写练习。未发表,仅作记录。',
|
||
long:'《夜半笔记》是一组在 2024 年间断续记录的速写,多数完成于深夜。题材并不指向具体——一段对话残片、一个尚未存在的物件、一种未被命名的情绪。\n\n它们没有被装订成册,也没有发表过,但工作室认为它们是这一年里最诚实的工作。',
|
||
spec:[['Client','个人创作'],['Year','2024'],['Scope','速写 · 38 张'],['Status','未发表']]
|
||
},
|
||
];
|
||
|
||
// ====== 球面投影:把作品按经纬网铺在一个球面上 ======
|
||
// 思路:每个 cell 在球面上有固定的 (col, row) → (经度, 纬度)。
|
||
// 相邻 cell 沿经线 / 纬线共享角点,所以边自然贴合,像地球仪上的经纬网。
|
||
// 拖拽 = 转动相机经纬度;缩放 = 球面整体 scale。
|
||
(() => {
|
||
const SPHERE_R = 1700; // 圆柱半径
|
||
const STEP_DEG = 11; // 每格经/纬跨度
|
||
const STEP_RAD = STEP_DEG * Math.PI / 180;
|
||
const CELL = Math.round(SPHERE_R * STEP_RAD); // ≈ 326
|
||
const TOTAL_COLS = Math.round(360 / STEP_DEG); // 经度方向无限循环:用 mod 取作品
|
||
const ROW_HALF = 6; // 上下各 6 行 ≈ ±66°,避开极区严重畸变
|
||
const ROW_MIN = -ROW_HALF, ROW_MAX = ROW_HALF;
|
||
|
||
document.documentElement.style.setProperty('--cell', CELL + 'px');
|
||
|
||
const viewport = document.getElementById('viewport');
|
||
const sphere = document.getElementById('sphere');
|
||
const posEl = document.getElementById('pos');
|
||
const zoomEl = document.getElementById('zoom');
|
||
|
||
// 相机:经度(可无限旋转)、纬度(夹紧)、缩放
|
||
let camLon = 0, camLat = 0, camZoom = 1;
|
||
const MIN_Z = 0.6, MAX_Z = 1.8;
|
||
|
||
// 鼠标视角微转:根据鼠标在屏幕的位置,整个球面轻微 yaw / pitch
|
||
const TILT_Y_AMP = 6; // 左右最大旋转角度(度)
|
||
const TILT_X_AMP = 4; // 上下最大旋转角度(度)
|
||
let tiltY = 0, tiltX = 0; // 当前实际值(写到 sphere transform)
|
||
let tiltTargetY = 0, tiltTargetX = 0; // 目标值(鼠标位置算出来)
|
||
let tiltAnimating = false;
|
||
|
||
const cellEls = new Map(); // key "col,row" → element
|
||
|
||
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
|
||
function tagHTML(tags){ return tags.map(t => `<em>${t}</em>`).join(''); }
|
||
|
||
// (col, row) → 选哪个作品。混合两个素数避免明显重复
|
||
function workFor(col, row){
|
||
const colN = ((col % TOTAL_COLS) + TOTAL_COLS) % TOTAL_COLS;
|
||
const rowN = row + ROW_HALF;
|
||
const idx = (colN * 7 + rowN * 13) % WORKS.length;
|
||
return WORKS[idx];
|
||
}
|
||
|
||
function ensureCell(col, row){
|
||
const key = col + ',' + row;
|
||
let el = cellEls.get(key);
|
||
if(el) return el;
|
||
const w = workFor(col, row);
|
||
el = document.createElement('div');
|
||
el.className = 'work';
|
||
el.dataset.id = w.id;
|
||
el.dataset.col = col;
|
||
el.dataset.row = row;
|
||
el.innerHTML = `
|
||
<div class="cell-head"><span class="brand">${w.brand}</span><span class="proj">${w.title}</span></div>
|
||
<div class="frame"><span class="num">${w.id}</span>
|
||
<img src="${w.img}" alt="${w.title}" loading="lazy">
|
||
</div>
|
||
<div class="cell-foot"><span class="year">${w.year}</span>
|
||
<span class="tags">${tagHTML(w.tags)}</span>
|
||
</div>`;
|
||
sphere.appendChild(el);
|
||
cellEls.set(key, el);
|
||
return el;
|
||
}
|
||
|
||
function placeCell(el, col, row){
|
||
// cell 在圆柱坐标:横向绕圈(经度),纵向走直线(行号)
|
||
const lonDeg = col * STEP_DEG - camLon;
|
||
const yPos = (row * STEP_DEG - camLat) / STEP_DEG * CELL;
|
||
// 内凹圆柱:相机在轴心,cell 贴在圆柱内壁
|
||
// 横向:rotateY 绕中轴卷起 → 纬线向中心凹
|
||
// 纵向:直接 translateY → 经线是直线(不再赤道外凸)
|
||
// 单 cell 略放大 1.008 吸收浮点缝隙
|
||
el.style.transform =
|
||
`rotateY(${-lonDeg}deg) translateZ(${-SPHERE_R}px) translateY(${yPos}px) scale(1.008)`;
|
||
}
|
||
|
||
function visibleRange(){
|
||
// 视野半角:屏幕越大、缩放越小,可见角度越大
|
||
const halfH = (Math.atan2(window.innerWidth / 2, 1500) * 180 / Math.PI) / camZoom + STEP_DEG;
|
||
const halfV = (Math.atan2(window.innerHeight / 2, 1500) * 180 / Math.PI) / camZoom + STEP_DEG;
|
||
const c0 = Math.floor((camLon - halfH) / STEP_DEG);
|
||
const c1 = Math.ceil ((camLon + halfH) / STEP_DEG);
|
||
const r0 = Math.max(ROW_MIN, Math.floor((camLat - halfV) / STEP_DEG));
|
||
const r1 = Math.min(ROW_MAX, Math.ceil ((camLat + halfV) / STEP_DEG));
|
||
return [c0, c1, r0, r1];
|
||
}
|
||
|
||
function render(){
|
||
const [c0, c1, r0, r1] = visibleRange();
|
||
const need = new Set();
|
||
for(let col = c0; col <= c1; col++){
|
||
for(let row = r0; row <= r1; row++){
|
||
const key = col + ',' + row;
|
||
need.add(key);
|
||
const el = ensureCell(col, row);
|
||
placeCell(el, col, row);
|
||
}
|
||
}
|
||
for(const [key, el] of cellEls){
|
||
if(!need.has(key)){ el.remove(); cellEls.delete(key); }
|
||
}
|
||
sphere.style.transform = `translate(-50%, -50%) rotateY(${tiltY}deg) rotateX(${tiltX}deg) scale(${camZoom})`;
|
||
if(posEl) posEl.textContent = `${Math.round(((camLon % 360) + 360) % 360)}°, ${Math.round(camLat)}°`;
|
||
if(zoomEl) zoomEl.textContent = camZoom.toFixed(2) + '×';
|
||
}
|
||
|
||
function recenter(){
|
||
camLon = 0; camLat = 0; camZoom = 1;
|
||
render();
|
||
}
|
||
|
||
recenter();
|
||
window.addEventListener('resize', render);
|
||
|
||
// —— 拖拽 / 点击 ——
|
||
let isDown = false, sx = 0, sy = 0, oLon = 0, oLat = 0;
|
||
let vx = 0, vy = 0, lastX = 0, lastY = 0, lastT = 0;
|
||
let dragMoved = 0, downTarget = null;
|
||
|
||
// 像素 → 度的灵敏度:80 px ≈ 1 格
|
||
const SENS = STEP_DEG / 80;
|
||
|
||
function down(x, y, target){
|
||
isDown = true; sx = x; sy = y; oLon = camLon; oLat = camLat;
|
||
lastX = x; lastY = y; lastT = performance.now();
|
||
vx = vy = 0; dragMoved = 0; downTarget = target;
|
||
document.body.classList.add('dragging');
|
||
}
|
||
function move(x, y){
|
||
if(!isDown) return;
|
||
camLon = oLon - (x - sx) * SENS / camZoom;
|
||
camLat = clamp(oLat + (y - sy) * SENS / camZoom, ROW_MIN * STEP_DEG, ROW_MAX * STEP_DEG);
|
||
dragMoved += Math.abs(x - lastX) + Math.abs(y - lastY);
|
||
render();
|
||
const now = performance.now();
|
||
const dt = Math.max(1, now - lastT);
|
||
vx = (x - lastX) / dt;
|
||
vy = (y - lastY) / dt;
|
||
lastX = x; lastY = y; lastT = now;
|
||
}
|
||
function up(){
|
||
if(!isDown) return;
|
||
isDown = false;
|
||
document.body.classList.remove('dragging');
|
||
if(dragMoved < 8 && downTarget){
|
||
const work = downTarget.closest('.work');
|
||
if(work){
|
||
const id = work.dataset.id;
|
||
if(id){ location.hash = '#work/' + id; }
|
||
}
|
||
}
|
||
// 惯性
|
||
const decay = 0.93, minV = 0.02;
|
||
function step(){
|
||
if(Math.abs(vx) < minV && Math.abs(vy) < minV) return;
|
||
camLon -= vx * 16 * SENS / camZoom;
|
||
camLat = clamp(camLat + vy * 16 * SENS / camZoom, ROW_MIN * STEP_DEG, ROW_MAX * STEP_DEG);
|
||
vx *= decay; vy *= decay;
|
||
render();
|
||
requestAnimationFrame(step);
|
||
}
|
||
requestAnimationFrame(step);
|
||
}
|
||
|
||
viewport.addEventListener('mousedown', e => { if(e.button !== 0) return; down(e.clientX, e.clientY, e.target); });
|
||
window.addEventListener('mousemove', e => {
|
||
move(e.clientX, e.clientY);
|
||
if(!isDown) setMouseTilt(e.clientX, e.clientY);
|
||
});
|
||
window.addEventListener('mouseup', up);
|
||
|
||
function setMouseTilt(x, y){
|
||
// 屏幕中心为 0,左右 ±1
|
||
const nx = (x / window.innerWidth - 0.5) * 2;
|
||
const ny = (y / window.innerHeight - 0.5) * 2;
|
||
tiltTargetY = nx * TILT_Y_AMP; // 鼠标在右 → 球面向右转一点
|
||
tiltTargetX = -ny * TILT_X_AMP; // 鼠标在下 → 球面向下俯一点(-ny 让方向更自然)
|
||
if(!tiltAnimating){ tiltAnimating = true; requestAnimationFrame(tickTilt); }
|
||
}
|
||
function tickTilt(){
|
||
tiltY += (tiltTargetY - tiltY) * 0.08;
|
||
tiltX += (tiltTargetX - tiltX) * 0.08;
|
||
render();
|
||
if(Math.abs(tiltTargetY - tiltY) > 0.02 || Math.abs(tiltTargetX - tiltX) > 0.02){
|
||
requestAnimationFrame(tickTilt);
|
||
} else {
|
||
tiltAnimating = false;
|
||
}
|
||
}
|
||
|
||
viewport.addEventListener('touchstart', e => { if(e.touches.length === 1) down(e.touches[0].clientX, e.touches[0].clientY, e.target); }, { passive: true });
|
||
window.addEventListener('touchmove', e => { if(e.touches.length === 1) move(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true });
|
||
window.addEventListener('touchend', up);
|
||
|
||
viewport.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
if(e.ctrlKey || e.metaKey){
|
||
camZoom = clamp(camZoom * Math.exp(-e.deltaY * 0.01), MIN_Z, MAX_Z);
|
||
render();
|
||
} else {
|
||
const isTrackpad = (Math.abs(e.deltaY) < 50 && !Number.isInteger(e.deltaY)) || Math.abs(e.deltaX) > 0;
|
||
if(isTrackpad){
|
||
camLon += e.deltaX * SENS / camZoom;
|
||
camLat = clamp(camLat + e.deltaY * SENS / camZoom, ROW_MIN * STEP_DEG, ROW_MAX * STEP_DEG);
|
||
render();
|
||
} else {
|
||
camZoom = clamp(camZoom * Math.exp(-e.deltaY * 0.0015), MIN_Z, MAX_Z);
|
||
render();
|
||
}
|
||
}
|
||
}, { passive: false });
|
||
|
||
window.addEventListener('keydown', e => {
|
||
if(e.key === 'Escape'){ if(location.hash){ location.hash = ''; } return; }
|
||
if(location.hash) return;
|
||
if(e.key === '0' || e.key === 'r' || e.key === 'R'){ recenter(); return; }
|
||
if(e.key === '+' || e.key === '='){ camZoom = clamp(camZoom * 1.15, MIN_Z, MAX_Z); render(); return; }
|
||
if(e.key === '-' || e.key === '_'){ camZoom = clamp(camZoom * 0.87, MIN_Z, MAX_Z); render(); return; }
|
||
const K = STEP_DEG;
|
||
if(e.key === 'ArrowLeft'){ camLon -= K; render(); }
|
||
if(e.key === 'ArrowRight'){ camLon += K; render(); }
|
||
if(e.key === 'ArrowUp'){ camLat = clamp(camLat - K, ROW_MIN * STEP_DEG, ROW_MAX * STEP_DEG); render(); }
|
||
if(e.key === 'ArrowDown'){ camLat = clamp(camLat + K, ROW_MIN * STEP_DEG, ROW_MAX * STEP_DEG); render(); }
|
||
});
|
||
|
||
// 入场:从一个稍微偏的角度滑回中心
|
||
setTimeout(() => {
|
||
document.getElementById('splash').classList.add('hide');
|
||
setTimeout(() => {
|
||
const start = performance.now();
|
||
const dur = 1400;
|
||
const dLon = -8, dLat = -3;
|
||
const startLon = camLon, startLat = camLat;
|
||
camLon = startLon - dLon; camLat = startLat - dLat;
|
||
function tick(t){
|
||
const k = Math.min(1, (t - start) / dur);
|
||
const ee = 1 - Math.pow(1 - k, 3);
|
||
camLon = startLon - dLon * (1 - ee);
|
||
camLat = startLat - dLat * (1 - ee);
|
||
render();
|
||
if(k < 1) requestAnimationFrame(tick);
|
||
}
|
||
requestAnimationFrame(tick);
|
||
}, 200);
|
||
}, 800);
|
||
})();
|
||
|
||
// ====== 时钟 ======
|
||
function updateClock(){
|
||
const now = new Date();
|
||
const h = String(now.getHours()).padStart(2, '0');
|
||
const m = String(now.getMinutes()).padStart(2, '0');
|
||
const t1 = document.getElementById('time1');
|
||
const t2 = document.getElementById('time2');
|
||
if(t1) t1.textContent = `${h}:${m}`;
|
||
const jH = String((now.getHours() + 1) % 24).padStart(2, '0');
|
||
if(t2) t2.textContent = `${jH}:${m} JST`;
|
||
}
|
||
updateClock(); setInterval(updateClock, 30000);
|
||
|
||
// ====== 二级页面 / 路由 ======
|
||
const overlays = {
|
||
about: document.getElementById('overlay-about'),
|
||
cv: document.getElementById('overlay-cv'),
|
||
contact: document.getElementById('overlay-contact'),
|
||
filter: document.getElementById('overlay-filter'),
|
||
detail: document.getElementById('overlay-detail'),
|
||
};
|
||
const detailContent = document.getElementById('detail-content');
|
||
|
||
function specHTML(spec){ return spec.map(([k,v])=>`<div class="row"><span class="k">${k}</span><span class="v">${v}</span></div>`).join(''); }
|
||
function detailTagsHTML(tags){ return tags.map(t=>`<em>${t}</em>`).join(''); }
|
||
|
||
function renderDetail(id){
|
||
const idx = WORKS.findIndex(w => w.id === id);
|
||
if(idx === -1) return false;
|
||
const w = WORKS[idx];
|
||
const prev = WORKS[(idx - 1 + WORKS.length) % WORKS.length];
|
||
const next = WORKS[(idx + 1) % WORKS.length];
|
||
detailContent.innerHTML = `
|
||
<div class="detail-num">作品 ${w.id} · ${w.year}</div>
|
||
<div class="detail-brand">${w.brand}</div>
|
||
<h1 class="detail-title">${w.title}</h1>
|
||
<div class="detail-tags">${detailTagsHTML(w.tags)}</div>
|
||
<div class="detail-hero"><img src="${w.hero}" alt="${w.title}"></div>
|
||
<div class="detail-body">
|
||
<div class="detail-desc">${w.long.replace(/\n/g,'<br><br>')}</div>
|
||
<div class="detail-spec">${specHTML(w.spec)}</div>
|
||
</div>
|
||
<div class="detail-gallery">
|
||
<div><img src="${w.g1}" alt="${w.title}"></div>
|
||
<div><img src="${w.g2}" alt="${w.title}"></div>
|
||
</div>
|
||
<div class="detail-nav">
|
||
<a data-nav-to="work/${prev.id}">
|
||
<span class="label">← 上一件</span>
|
||
<span>${prev.title}</span>
|
||
</a>
|
||
<a data-nav-to="work/${next.id}" class="next">
|
||
<span class="label">下一件 →</span>
|
||
<span>${next.title}</span>
|
||
</a>
|
||
</div>`;
|
||
detailContent.querySelectorAll('[data-nav-to]').forEach(a => {
|
||
a.addEventListener('click', () => { location.hash = '#' + a.dataset.navTo; });
|
||
});
|
||
return true;
|
||
}
|
||
|
||
function showOverlay(name){
|
||
for(const k in overlays) overlays[k].classList.remove('show');
|
||
if(overlays[name]) overlays[name].classList.add('show');
|
||
document.body.classList.toggle('overlay-open', !!name);
|
||
document.body.style.overflow = name ? 'hidden' : '';
|
||
}
|
||
|
||
function applyRoute(){
|
||
const h = location.hash.replace(/^#/, '');
|
||
const m = h.match(/^work\/(.+)$/);
|
||
if(m){
|
||
if(renderDetail(m[1])){ showOverlay('detail'); window.scrollTo(0,0); }
|
||
else showOverlay(null);
|
||
setActiveTab(null);
|
||
return;
|
||
}
|
||
if(['about','cv','contact','filter'].includes(h)){
|
||
showOverlay(h);
|
||
setActiveTab(h);
|
||
return;
|
||
}
|
||
showOverlay(null);
|
||
setActiveTab('home');
|
||
}
|
||
|
||
function setActiveTab(name){
|
||
document.querySelectorAll('.tab').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.nav === name);
|
||
});
|
||
}
|
||
|
||
window.addEventListener('hashchange', applyRoute);
|
||
|
||
// nav clicks
|
||
document.querySelectorAll('[data-nav]').forEach(el => {
|
||
el.addEventListener('click', e => {
|
||
e.preventDefault();
|
||
const target = el.dataset.nav;
|
||
if(target === 'home' || target === 'list'){
|
||
if(location.hash) { location.hash = ''; }
|
||
} else {
|
||
location.hash = '#' + target;
|
||
}
|
||
});
|
||
});
|
||
|
||
applyRoute();
|
||
</script>
|
||
</body>
|
||
</html>
|