This commit is contained in:
toom1996
2026-09-20 00:44:14 +08:00
parent b04a511b26
commit 4b409b5a29
83 changed files with 12072 additions and 218 deletions

View File

@ -36,16 +36,16 @@ const fullLoginLabel = t('go to full login')
<button type="button" @click="close()" class="-mt-1 text-[11px] uppercase tracking-widest opacity-60 transition-opacity hover:opacity-100">{closeLabel}</button>
</div>
<p class="mb-7 text-[11px] uppercase tracking-widest text-zinc-400">{prompt}</p>
<p class="mb-7 text-[11px] uppercase tracking-widest text-[var(--c-gray-600)]">{prompt}</p>
<form @submit.prevent="submit()" class="space-y-6" novalidate>
<div>
<label class="mb-2 block text-[11px] uppercase tracking-widest text-zinc-500">{accountLabel}</label>
<label class="mb-2 block text-[11px] uppercase tracking-widest text-[var(--c-gray-700)]">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-2 text-sm outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div>
<label class="mb-2 block text-[11px] uppercase tracking-widest text-zinc-500">{passwordLabel}</label>
<label class="mb-2 block text-[11px] uppercase tracking-widest text-[var(--c-gray-700)]">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-2 text-sm outline-none transition-colors focus:border-black focus:ring-0" />
</div>
@ -60,7 +60,7 @@ const fullLoginLabel = t('go to full login')
</form>
<div class="mt-6 border-t border-black/10 pt-4 text-center">
<button type="button" @click="goFullLogin()" class="text-[10px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">{fullLoginLabel}</button>
<button type="button" @click="goFullLogin()" class="text-[10px] uppercase tracking-[0.2em] text-[var(--c-gray-600)] transition-colors hover:text-black">{fullLoginLabel}</button>
</div>
</div>
</div>

View File

@ -93,13 +93,13 @@
@apply m-0 text-[12px] tracking-[0.22em] uppercase font-bold;
}
.showsmodal-x {
@apply appearance-none bg-transparent border-0 w-6 h-6 inline-flex items-center justify-center cursor-pointer text-[14px] leading-none text-[#999] transition-colors hover:text-black;
@apply appearance-none bg-transparent border-0 w-6 h-6 inline-flex items-center justify-center cursor-pointer text-[14px] leading-none text-[var(--c-gray-600)] transition-colors hover:text-black;
}
.showsmodal-search {
@apply px-4 pt-1 pb-3 shrink-0;
}
.showsmodal-input {
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black;
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[var(--c-gray-50)] focus:bg-white focus:border-black;
}
.showsmodal-tip {
@apply mt-2 text-[11px] leading-snug text-black/45;
@ -120,10 +120,10 @@
@apply flex-1 min-h-0 overflow-y-auto px-2 py-1 overscroll-contain;
}
.showsmodal-hint {
@apply text-[10px] tracking-[0.22em] uppercase text-[#999] px-3 py-2;
@apply text-[10px] tracking-[0.22em] uppercase text-[var(--c-gray-600)] px-3 py-2;
}
.showsmodal-loading {
@apply flex justify-center py-6 text-[#bdbdbd];
@apply flex justify-center py-6 text-[var(--c-gray-500)];
}
.showsmodal-list {
@apply list-none m-0 p-0;
@ -142,10 +142,10 @@
@apply flex-1 truncate;
}
.showsmodal-empty {
@apply py-6 text-center text-[#999] text-[13px];
@apply py-6 text-center text-[var(--c-gray-600)] text-[13px];
}
.showsmodal-foot {
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888];
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[var(--c-gray-700)];
}
.showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85;

View File

@ -41,7 +41,7 @@ const { locale } = getI18n(Astro)
justify-content: space-between;
border-top: 1px solid #000;
padding-top: 18px;
@apply text-[11px] tracking-[0.25em] uppercase text-[#666];
@apply text-[11px] tracking-[0.25em] uppercase text-[var(--c-gray-800)];
}
.cs-title {
@apply font-black tracking-[-0.02em];
@ -54,7 +54,7 @@ const { locale } = getI18n(Astro)
margin: 0 0 18px;
}
.cs-desc {
@apply text-[13px] text-[#666];
@apply text-[13px] text-[var(--c-gray-800)];
line-height: 1.7;
max-width: 52ch;
margin: 0 0 28px;

View File

@ -1,7 +1,7 @@
---
// 走秀 / 街拍详情页共享的全屏大图灯箱:左栏缩略图条 + 大图 + 上/下·左/右翻页 + 单图收藏。
//
// 视觉遵循全站「黑白编辑风」:直角细框、mono 小字 + 宽字距、零填充计数、hover 反色(bg-black + 白字)。
// 视觉遵循全站「黑白编辑风」:直角细框、小字 + 宽字距、零填充计数、hover 反色(bg-black + 白字)。
// 交互逻辑全部在 src/lib/gallery.ts(createGalleryView);本组件只负责标记。
//
// 各页独有的附加层通过具名插槽 details 注入(走秀页塞「当前 look 的细节图簇」,街拍页不传)。
@ -29,7 +29,7 @@ const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
aria-hidden="true"
>
{/* 计数:mono + 0.22em 字距(全站「小标与计数」档),直角细框,零填充 */}
{/* 计数:小字 + 0.22em 字距(全站「小标与计数」档),直角细框,零填充 */}
<div
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 border border-black/15 bg-white/90 px-3 py-1 text-[11px] tracking-[0.22em] text-[#111] backdrop-blur-md"
x-text="`${String(lbIndex + 1).padStart(2, '0')} / ${String(imageCount).padStart(2, '0')}`"
@ -71,7 +71,7 @@ const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
data-lb-index={i}
data-thumb-url={im.thumb}
@click={`showLb(${i})`}
class={`lb-thumb relative block w-full ${thumbAspect} mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center`}
class={`lb-thumb relative block w-full ${thumbAspect} mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[var(--c-gray-200)] bg-cover bg-center`}
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
aria-label={`${i + 1}`}
>
@ -87,7 +87,7 @@ const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
:data-lb-index="5 + i"
:data-thumb-url="im.thumb"
@click="showLb(5 + i)"
class={`lb-thumb relative block w-full ${thumbAspect} mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center`}
class={`lb-thumb relative block w-full ${thumbAspect} mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[var(--c-gray-200)] bg-cover bg-center`}
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
:aria-label="String(5 + i + 1)"
>

View File

@ -1,6 +1,6 @@
---
// 加载屏:首屏/整页切换时遮盖未渲染内容,避免 FOUC。
// 轻量原则(用户要求"朴实流畅"):
// 轻量原则(追求朴实流畅):
// - 只用静态大字 + 小标 + 一条 2px 细线进度(transform/opacity 动画,最便宜);
// - 去掉昂贵的多层 text-shadow 位移动画与外部字体;最短展示时间压缩到 ~350ms,跟手不拖沓。
@ -48,14 +48,14 @@ const { t } = getI18n(Astro)
font-size: 0.72rem;
letter-spacing: 0.34em;
text-transform: uppercase;
color: #888;
color: var(--c-gray-700);
animation: ls-pulse 1.4s ease-in-out infinite;
}
#loading-screen .ls-bar {
margin-top: 2em;
width: min(180px, 60vw);
height: 2px;
background: #e6e6e6;
background: var(--c-gray-300);
overflow: hidden;
}
#loading-screen .ls-bar::before {

View File

@ -89,11 +89,11 @@ const homeHref = href(locale, ROUTES.home)
<section class="mt-16">
{/* 每行张数切换(应对图集图片很多的情况):2/3/4/5 列,移动端固定 2 列 */}
<div class="flex items-center justify-between mb-5 border-b border-black/10 pb-3">
<span class="text-[11px] uppercase tracking-[0.22em] text-[#999]">
<span class="text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">
{(imageTotal || images.length)} LOOKS
</span>
<div class="flex items-center gap-1" role="group" aria-label="Images per row">
<span class="text-[11px] uppercase tracking-[0.18em] text-[#999] mr-2 hidden sm:inline">PER ROW</span>
<span class="text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-600)] mr-2 hidden sm:inline">PER ROW</span>
{/* 每行张数切换:只有「3 张 / 5 张」两档,用竖条图标直观表达列数(避免文字数字按钮)。
density 只影响 md+;移动端固定 2 列(5 列在手机上不可用)。 */}
<button
@ -145,7 +145,7 @@ const homeHref = href(locale, ROUTES.home)
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="relative bg-[#f2f2f2] cursor-zoom-in group"
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
src={im.url}
@ -202,10 +202,10 @@ const homeHref = href(locale, ROUTES.home)
x-show="!isAuthed && preview"
x-cloak
login-redirect
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
class="relative bg-[var(--c-gray-100)] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[2/3] flex items-center justify-center text-black group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
<div class="w-full aspect-[2/3] flex items-center justify-center text-black group-hover:text-[var(--c-gray-700)] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
@ -231,7 +231,7 @@ const homeHref = href(locale, ROUTES.home)
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[#f2f2f2] cursor-zoom-in group"
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
:src="im.url"
@ -285,7 +285,7 @@ const homeHref = href(locale, ROUTES.home)
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="text-sm text-[#888] uppercase tracking-widest mb-4">
<p class="text-sm text-[var(--c-gray-700)] uppercase tracking-widest mb-4">
{error === "missing" ? t('No article ID specified') : t('Failed to load article. Please refresh or try later.')}
</p>
@ -312,13 +312,13 @@ const homeHref = href(locale, ROUTES.home)
x-cloak
class="absolute right-4 sm:right-6 bottom-[72px] z-30 flex flex-col items-end gap-2"
>
<span class="text-[10px] uppercase tracking-[0.22em] text-[#999]">Details</span>
<span class="text-[10px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">Details</span>
<div class="flex max-w-[min(70vw,440px)] gap-2 overflow-x-auto no-scrollbar">
<template x-for="(d, i) in currentDetailList" :key="d.url">
<button
type="button"
@click.stop="openDetailLbAt(i)"
class="relative flex-none w-[52px] sm:w-[68px] aspect-[2/3] overflow-hidden border border-black/15 bg-[#eee] cursor-zoom-in transition-colors hover:border-black"
class="relative flex-none w-[52px] sm:w-[68px] aspect-[2/3] overflow-hidden border border-black/15 bg-[var(--c-gray-200)] cursor-zoom-in transition-colors hover:border-black"
:aria-label="`Detail ${i + 1}`"
>
<img :src="d.thumb" :alt="d.name || ''" loading="lazy" decoding="async" class="w-full h-full object-cover" />
@ -459,7 +459,7 @@ const homeHref = href(locale, ROUTES.home)
// 为什么要把这个类型写出来:`Alpine.data` 的泛型 T 靠**对象字面量**推断,而这里用了对象展开
// ({...createGalleryView()}),推断会退化成 {},于是方法里的 this 只剩 Alpine 魔法属性,
// 所有 this.xxx 都报「属性不存在」。给回调标注返回类型,T 才有确定值。
// (@types/alpinejs 那个自引用泛型约束的老毛病,详见 MEMORY.md。)
// (@types/alpinejs 那个自引用泛型约束的老毛病。)
type ArticleExtras = {
density: number
currentDetailName: string

View File

@ -72,7 +72,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="relative bg-[#f2f2f2] cursor-zoom-in group"
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
src={im.url}
@ -99,7 +99,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</button>
{im.name && (
<figcaption class="text-[11px] uppercase tracking-widest text-[#999] mt-2">
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2">
{im.name}
</figcaption>
)}
@ -114,10 +114,10 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
x-show="!isAuthed && preview"
x-cloak
login-redirect
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
class="relative bg-[var(--c-gray-100)] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none">
<div class="w-full aspect-[3/4] flex items-center justify-center text-[var(--c-gray-600)] group-hover:text-[var(--c-gray-700)] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
@ -128,7 +128,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</div>
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 img01 等小字):
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
<figcaption class="text-[11px] uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption>
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2">+{lockedCount} {t('photos')}</figcaption>
</figure>
))
}
@ -142,7 +142,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[#f2f2f2] cursor-zoom-in group"
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
:src="im.url"
@ -166,7 +166,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</svg>
</button>
<template x-if="im.name">
<figcaption class="text-[11px] uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption>
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2" x-text="im.name"></figcaption>
</template>
</figure>
</template>
@ -177,7 +177,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="text-sm text-[#888] uppercase tracking-widest mb-4">
<p class="text-sm text-[var(--c-gray-700)] uppercase tracking-widest mb-4">
{error === "missing" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')}
</p>
@ -204,7 +204,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
import { createGalleryView } from "@/lib/gallery"
// 图集 + 大图灯箱的共享逻辑全在 lib/gallery.ts;街拍详情没有额外扩展,只补一个 init 调共享初始化。
// 回调显式标注返回类型:对象展开会让 Alpine.data 的泛型 T 退化成 {},this.xxx 全部报错(见 Item.astro 说明)。
// 回调显式标注返回类型:对象展开会让 Alpine.data 的泛型 T 退化成 {},this.xxx 全部报错(见 RunwayLookCard.astro 说明)。
document.addEventListener("alpine:init", () => {
Alpine.data(
"streetSnapView",

View File

@ -32,12 +32,12 @@ const noHistoryLabel = t("no history yet")
const browseLabel = t("browse the archive")
---
<Layout title={titleLabel} description={accountDesc} keywords={accountKeywords}>
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
<!-- 页面版心与 Layout 一致:黑白编辑风,粗黑大标题 + 宽字距小标 + 粗黑细线 -->
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}>
<!-- ===== 未登录:整版引导 ===== -->
<div x-show="!user" x-cloak class="flex flex-col items-center justify-center border border-black/15 px-6 py-32 text-center">
<p class="mb-5 text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
<p class="mb-5 text-[11px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">{pleaseLabel}</p>
<p class="mb-12 text-5xl font-black uppercase leading-none tracking-tight md:text-7xl">{titleLabel}</p>
<a href={loginUrl} class="inline-flex items-center justify-center border border-black bg-black px-10 py-4 text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
</div>
@ -45,19 +45,19 @@ const browseLabel = t("browse the archive")
<!-- ===== 已登录 ===== -->
<div x-show="user" x-cloak>
<!-- 报头:衬线大标题会员名 + 档案标签 -->
<!-- 报头:粗黑大标题会员名 + 档案标签 -->
<header class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8 border-b-2 border-black pb-10 pt-2">
<div class="min-w-0">
<p class="mb-4 text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
<p class="mb-4 text-[11px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">{t("member profile")}</p>
<div class="flex flex-wrap items-end gap-x-4">
<h1 class="text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl" x-text="(user?.username || '')"></h1>
<!-- VIP 徽标:仅高级用户(图集详情页可见高清原图)显示 -->
<span x-show="user?.tier === 'vip'" x-cloak class="mb-2 inline-block border border-black bg-black px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
</div>
</div>
<div class="shrink-0 pb-1 text-right text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<div class="shrink-0 pb-1 text-right text-[11px] uppercase leading-loose tracking-[0.22em] text-[var(--c-gray-600)]">
<p>{SITE_NAME}</p>
<p class="flex items-center justify-end gap-2 text-[var(--c-gray-700)]">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
@ -69,7 +69,7 @@ const browseLabel = t("browse the archive")
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
<p class="mb-3 hidden text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
<p class="mb-3 hidden text-[10px] uppercase tracking-[0.3em] text-[var(--c-gray-600)] lg:block">Index</p>
<div class="flex gap-10 overflow-x-auto border-b border-black/15 text-[12px] uppercase tracking-[0.2em] lg:flex-col lg:gap-0 lg:overflow-visible lg:border-b-0 lg:border-t lg:border-black/10">
<button type="button" @click="tab='saved'" :class="tab==='saved' ? 'border-black text-black' : 'border-transparent text-black/40 hover:text-black'" class="-mb-px whitespace-nowrap border-b-2 px-0.5 pb-4 pt-1 text-left transition-colors lg:mb-0 lg:w-full lg:border-b-0 lg:border-l-2 lg:px-5 lg:py-4">
<span class="mr-3 text-[10px] opacity-60">01</span>{savedLooksLabel}
@ -86,7 +86,7 @@ const browseLabel = t("browse the archive")
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<h2 class="text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
<p class="pb-1 text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
<p class="pb-1 text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
</div>
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
@ -105,7 +105,7 @@ const browseLabel = t("browse the archive")
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
<template x-for="f in favorites" :key="f.id">
<div class="group">
<a :href="itemHref(f)" target="_blank" rel="noopener" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
<a :href="itemHref(f)" target="_blank" rel="noopener" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[var(--c-gray-100)] transition-colors group-hover:border-black">
<img :src="coverUrl(f)" :alt="f.title" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
</a>
<button @click="removeFav(f.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
@ -113,7 +113,7 @@ const browseLabel = t("browse the archive")
<a :href="itemHref(f)" target="_blank" rel="noopener" class="block">
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
</a>
<p class="mt-1.5 truncate text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
<p class="mt-1.5 truncate text-[10px] uppercase tracking-[0.16em] text-[var(--c-gray-600)]" x-text="f.brand || '—'"></p>
</div>
</div>
</template>
@ -138,8 +138,8 @@ const browseLabel = t("browse the archive")
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<h2 class="text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{historyLabel}</h2>
<div class="flex items-baseline gap-8 pb-1">
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
<p class="text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]"><span class="font-bold text-black" x-text="histTotal"></span> {itemsLabel}</p>
<button x-show="histTotal > 0" @click="clearAllHistory()" class="text-[11px] uppercase tracking-[0.2em] text-[var(--c-gray-600)] underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
</div>
</div>
@ -153,7 +153,7 @@ const browseLabel = t("browse the archive")
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5">
<template x-for="h in history" :key="h.id">
<div class="group">
<a :href="itemHref(h)" target="_blank" rel="noopener" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
<a :href="itemHref(h)" target="_blank" rel="noopener" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[var(--c-gray-100)] transition-colors group-hover:border-black">
<img :src="coverUrl(h)" :alt="h.data?.title || ''" loading="lazy" class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]" />
</a>
<button @click="removeHist(h.id)" class="card-act absolute right-2 top-2 border border-black/15 bg-white/90 px-2.5 py-1 text-[9px] uppercase tracking-[0.16em] text-black/70 opacity-0 backdrop-blur transition-opacity hover:bg-black hover:text-white group-hover:opacity-100">{removeLabel}</button>
@ -161,7 +161,7 @@ const browseLabel = t("browse the archive")
<a :href="itemHref(h)" target="_blank" rel="noopener" class="block">
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
</a>
<p class="mt-1.5 truncate text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
<p class="mt-1.5 truncate text-[10px] uppercase tracking-[0.16em] text-[var(--c-gray-600)]" x-text="h.data?.brand || '—'"></p>
</div>
</div>
</template>
@ -202,7 +202,7 @@ const browseLabel = t("browse the archive")
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
'<rect width="300" height="400" fill="#f1f1f1"/>' +
'<rect width="300" height="400" fill="var(--c-gray-100)"/>' +
'<text x="150" y="200" font-family="sans-serif" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
'</svg>'
)

View File

@ -79,7 +79,7 @@ const streetItems: {
<!-- Runway -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">
<div class="text-2xl font-medium mb-4">
Hot Brand
</div>
@ -207,20 +207,16 @@ const streetItems: {
<style>
@reference "tailwindcss";
:root {
--brand: #cf5a44;
}
/* 标签 */
.tab {
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
@apply relative pb-[6px] text-[var(--c-gray-600)] transition-colors duration-200 no-underline;
}
.tab:hover {
@apply text-[#666];
@apply text-[var(--c-gray-800)];
}
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-black scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
}
.tab.is-active {
@apply text-black;
@ -283,7 +279,7 @@ const streetItems: {
position: relative;
display: block;
height: clamp(500px, 80vh, 900px);
background: #f0f0f0;
background: var(--c-gray-100);
overflow: hidden;
}
.hs-img-box > img {
@ -307,7 +303,7 @@ const streetItems: {
position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-image: linear-gradient(100deg, var(--c-gray-100) 30%, var(--c-gray-300) 50%, var(--c-gray-100) 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
@ -321,7 +317,7 @@ const streetItems: {
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
color: var(--c-gray-500);
pointer-events: none;
z-index: 2;
transition: opacity 0.3s ease;
@ -390,22 +386,18 @@ const streetItems: {
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 768px) {
.hs-caption {
padding: 60px 28px 26px;
}
.hs-cap-brand {
font-size: 26px;
}
.hs-cap-meta {
font-size: 15px;
}
.hs-caption {
@apply md:px-7 md:pb-7 md:pt-14;
}
@media (max-width: 768px) {
.hs-swiper,
.hs-img-box {
height: clamp(340px, 56vh, 540px);
}
.hs-cap-brand {
@apply md:text-[26px];
}
.hs-cap-meta {
@apply md:text-[15px];
}
.hs-swiper,
.hs-img-box {
@apply max-md:h-[clamp(340px,56vh,540px)];
}
/* ========================= Street Style ========================= */
@ -416,17 +408,17 @@ const streetItems: {
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
}
.ss-card {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit;
@apply relative block w-full overflow-hidden bg-[var(--c-gray-100)] no-underline text-inherit;
aspect-ratio: 3 / 4;
}
.ss-img-box {
@apply absolute inset-0 w-full h-full overflow-hidden;
background: #f0f0f0;
background: var(--c-gray-100);
}
.ss-img-box > img {
@apply absolute inset-0 w-full h-full object-cover;
opacity: 0;
transition: opacity 0.45s ease, transform 0.7s ease;
transition: opacity 0.45s ease, transform 0.4s ease;
}
.ss-img-box.is-loaded > img {
opacity: 1;
@ -443,7 +435,7 @@ const streetItems: {
position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-image: linear-gradient(100deg, var(--c-gray-100) 30%, var(--c-gray-300) 50%, var(--c-gray-100) 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
@ -458,7 +450,7 @@ const streetItems: {
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
color: var(--c-gray-500);
pointer-events: none;
transition: opacity 0.3s ease;
}
@ -590,7 +582,12 @@ const streetItems: {
// 图片加载完成(每张图的 Alpine 组件 dispatch "img-ready")后更新 Swiper
window.addEventListener("img-ready", scheduleSwiperUpdate)
</script>
{/* 图片加载状态组件独立成块,刻意不依赖上面的 swiper 模块:
避免 swiper 依赖预构建异常(如 Vite 504 Outdated Optimize Dep)时,整段脚本不执行、
连 Alpine 的 imgLoader 注册一起失效,导致 x-data="imgLoader()" 报未定义。 */}
<script>
/* ---------------- 共享图片加载状态组件 ---------------- */
// Hot Brand 与 Street Style 复用同一份逻辑:
// 1) init() 检查 complete + naturalWidth,缓存已完成的图片立即置位,不一直显示骨架;

View File

@ -46,7 +46,7 @@ const loginKeywords = locale === 'cn'
<meta name="description" content={loginDesc} />
<meta name="keywords" content={loginKeywords} />
</head>
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
<!-- 全站一致:白底 + 粗黑大标题 + 宽字距小标 + 粗黑细线,直角无阴影 -->
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">
<main class="mx-auto w-full max-w-[1400px] px-5 py-10 md:px-10 md:py-14 font-sans">
@ -73,16 +73,16 @@ const loginKeywords = locale === 'cn'
</div>
</header>
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 2px 粗黑线) -->
<!-- 报头:与账户页同一套 masthead(宽字距眉标 + 粗黑巨标题 + 档案标签 + 2px 粗黑线) -->
<section class="border-b-2 border-black pb-10">
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-8">
<div>
<p class="mb-4 text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
<p class="mb-4 text-[11px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">{membersLabel}</p>
<h1 class="text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
</div>
<div class="pb-1 text-right text-[11px] uppercase leading-loose tracking-[0.22em] text-zinc-400">
<p>Portfolio Studio</p>
<p class="flex items-center justify-end gap-2 text-zinc-500">Archive
<div class="pb-1 text-right text-[11px] uppercase leading-loose tracking-[0.22em] text-[var(--c-gray-600)]">
<p>{SITE_NAME}</p>
<p class="flex items-center justify-end gap-2 text-[var(--c-gray-700)]">Archive
<span class="bg-black px-1.5 py-0.5 text-[10px] font-bold tracking-widest text-white">2026</span>
</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
@ -94,17 +94,17 @@ const loginKeywords = locale === 'cn'
<div class="grid gap-12 pt-12 md:grid-cols-[minmax(0,1fr)_320px] md:gap-16 md:pt-16">
<div class="w-full max-w-md">
<p class="mb-10 text-[11px] uppercase tracking-[0.22em] text-zinc-400">{promptLabel}</p>
<p class="mb-10 text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">{promptLabel}</p>
<form x-data="loginForm" data-locale={locale} data-fail-label={failLabel} @submit.prevent="submit()" class="space-y-8" novalidate>
<div>
<label class="mb-2 block text-[11px] uppercase tracking-[0.22em] text-zinc-500">{accountLabel}</label>
<label class="mb-2 block text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-700)]">{accountLabel}</label>
<input type="text" x-model="account" autocomplete="username" required autofocus
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
<div>
<label class="mb-2 block text-[11px] uppercase tracking-[0.22em] text-zinc-500">{passwordLabel}</label>
<label class="mb-2 block text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-700)]">{passwordLabel}</label>
<input type="password" x-model="password" autocomplete="current-password" required
class="w-full border-0 border-b border-black/30 bg-transparent px-0 py-3 text-sm text-black outline-none transition-colors focus:border-black focus:ring-0" />
</div>
@ -119,16 +119,16 @@ const loginKeywords = locale === 'cn'
</form>
<div class="mt-10 border-t border-black/15 pt-5">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.2em] text-zinc-400 transition-colors hover:text-black">
<a href={homeUrl} class="inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.2em] text-[var(--c-gray-600)] transition-colors hover:text-black">
<span aria-hidden="true">←</span>{backLabel}
</a>
</div>
</div>
<aside class="border-t border-black/10 pt-8 md:border-l md:border-t-0 md:pl-8 md:pt-0">
<p class="mb-5 text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
<p class="mb-5 text-[11px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">Archive note</p>
<p class="text-lg leading-relaxed text-black/80">{t('minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.')}</p>
<div class="mt-8 border-t border-black/10 pt-5 text-[10px] uppercase tracking-[0.3em] text-zinc-400">
<div class="mt-8 border-t border-black/10 pt-5 text-[10px] uppercase tracking-[0.3em] text-[var(--c-gray-600)]">
<p class="mb-3">Runway · Street · Archive</p>
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
</div>

View File

@ -162,7 +162,7 @@ const runwayGroups = [
<!-- Card List -->
<template x-for="it in items" :key="it.id">
<CollectionCard clien />
<CollectionCard />
</template>
</div>

View File

@ -40,7 +40,7 @@ const metaKeywords = keywords ?? defaultKeywords
// 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。
setApiLocale(locale)
// 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
// 经 data-* 属性把已翻译字符串传入打包型 script 标签;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
// (账号区现改为 Alpine 组件,译文直接在模板内用 t() 输出,无需 data 属性中转)
const pathName = Astro.url.pathname;
@ -51,7 +51,7 @@ const pathWithoutLocale = locales.includes(pathName.split('/')[1])
: pathName;
// 顶部导航数据模型(替代原 Alpine x-data 的 menu/state)。
// 下拉菜单与语言列表均由此驱动渲染,逻辑用 Astro 原生 <script> 控制开合。
// 下拉菜单与语言列表均由此驱动渲染,逻辑用 Astro 原生 script 控制开合。
const headers = {
// 走秀下拉菜单项:方案 A —— 全部 + 4 个真实 collection_type 分类(与列表页 COLLECTION 筛选一致),
// 深链带 ?collection= 参数,由 RunwayLooks 的 init() 读取并自动选中筛选。
@ -100,9 +100,9 @@ const headers = {
<meta name="description" content={metaDescription} />
<meta name="keywords" content={metaKeywords} />
</head>
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
<body class="bg-white text-black min-h-screen flex flex-col px-5 py-5 md:px-[50px] md:py-[30px] font-sans">
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingScreen.astro -->
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingOverlay.astro -->
<LoadingOverlay />
<!-- 顶部主导航栏 -->
@ -313,25 +313,24 @@ const headers = {
})
</script>
{/* Top Footer Grid */}
<div class="grid grid-cols-1 md:grid-cols-12 gap-8 mb-10 pb-10 border-b border-zinc-200">
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-8 mb-10 pb-10 border-b border-black/10">
{/* Column 1: Brand & Manifesto */}
<div class="md:col-span-5 space-y-4">
<div class="flex items-center gap-2">
<span class="text-base font-black tracking-wider uppercase border-b-2 border-black pb-0.5">
Portfolio Studio
{SITE_NAME}
</span>
<span class="text-[10px] bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest">
2026 Edition
<span class="text-[10px] bg-black text-white px-2 py-0.5 font-bold uppercase tracking-widest rounded-full">
2026
</span>
</div>
<p class="text-xs text-zinc-600 leading-relaxed max-w-md">
<p class="text-xs text-[var(--c-gray-800)] leading-relaxed max-w-md">
{t('Minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. Integrated with 2026 color forecast intelligence and AI palette generator.')}
</p>
{/* Location Badge */}
<div class="flex flex-wrap gap-3 text-[11px] text-zinc-500 uppercase tracking-widest pt-1">
<div class="flex flex-wrap gap-3 text-[11px] text-[var(--c-gray-700)] uppercase tracking-widest pt-1">
<span>Paris</span>
<span>•</span>
<span>Milan</span>
@ -341,10 +340,30 @@ const headers = {
<span>New York</span>
</div>
</div>
{/* Column 2: Explore */}
<div class="md:col-span-4 md:col-start-7 space-y-3">
<h3 class="text-[11px] tracking-[0.26em] uppercase font-bold text-black">Explore</h3>
<ul class="space-y-2 text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-800)]">
<li><a href={href(locale, ROUTES.runwayLooks)} class="hover:text-black transition-colors">{t('all runway looks')}</a></li>
<li><a href={href(locale, ROUTES.streetSnaps)} class="hover:text-black transition-colors">{t('Street Style')}</a></li>
<li><a href={href(locale, ROUTES.account)} class="hover:text-black transition-colors">{t('my account')}</a></li>
</ul>
</div>
{/* Column 3: Connect */}
<div class="md:col-span-2 space-y-3">
<h3 class="text-[11px] tracking-[0.26em] uppercase font-bold text-black">Connect</h3>
<ul class="space-y-2 text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-800)]">
<li><a href="#" class="hover:text-black transition-colors">Instagram</a></li>
<li><a href="#" class="hover:text-black transition-colors">Newsletter</a></li>
<li><a href="#" class="hover:text-black transition-colors">Contact</a></li>
</ul>
</div>
</div>
<div class="mt-6 text-[11px] text-zinc-400 uppercase tracking-widest">
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
<div class="mt-6 text-[11px] text-[var(--c-gray-600)] uppercase tracking-widest">
© 2026 {SITE_NAME}. {t('all rights reserved.')}
</div>
</footer>

View File

@ -1,6 +1,6 @@
// src/lib/crypto.ts — 前端 JS 请求签名(反爬一层,非加密)
//
// 仅做 HMAC-SHA256 计算,由 api.ts 的公开请求原语附带 X-Sign / X-Sign-Ts / X-Sign-Nonce 头,
// 仅做 HMAC-SHA256 计算,由 request.ts 的唯一请求入口 request() 附带 X-Sign / X-Sign-Ts / X-Sign-Nonce 头,
// 供后端 middleware.ClientSign 校验「请求大概率来自真前端」。
//
// 安全说明:secret 必须出现在前端 bundle 才能签名(对浏览器可见),故只是「提高成本」,

View File

@ -1,6 +1,6 @@
// src/lib/gallery.ts —— 走秀 / 街拍详情页共享的「图集 + 大图灯箱」Alpine 视图工厂。
//
// Item.astro(走秀)与 StreetSnap.astro(街拍)的灯箱交互逐行同构,差异只有三处:
// RunwayLookCard.astro(走秀)与 StreetSnapCard.astro(街拍)的灯箱交互逐行同构,差异只有三处:
// ① 根 / 画廊选择器 ② 单图收藏类型 ③ 已登录全量取数函数
// 故抽成工厂;各自独有的部分(每行张数、细节图灯箱)由组件在返回值上自行叠加。
//
@ -79,6 +79,8 @@ export function createGalleryView(cfg: GalleryConfig) {
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的
// getUser,故在此初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
isAuthed: false,
// 登录事件处理器引用:组件销毁(destroy)时据此移除全局监听,避免跨页面 / 视图切换累积监听器泄漏。
onAuthLogin: null as (() => void) | null,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false,
// 图集级收藏态:由 loadAuthedState 取回,广播给页面右下角 fav-fab
@ -107,11 +109,22 @@ export function createGalleryView(cfg: GalleryConfig) {
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
if (getUser()) this.loadAuthedState()
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
window.addEventListener("auth:login", () => {
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态。
// 用命名引用保存处理器,组件销毁(destroy)时据此移除,避免跨页面 / 视图切换累积监听器泄漏。
this.onAuthLogin = () => {
this.isAuthed = true
if (getUser()) this.loadAuthedState()
})
}
window.addEventListener("auth:login", this.onAuthLogin)
},
// 组件销毁时移除全局登录监听(Alpine 在 x-data 根节点移除时自动调用 destroy),
// 防止内存 / 监听器随路由切换(尤其是启用 View Transitions 时)不断累积。
destroy() {
if (this.onAuthLogin) {
window.removeEventListener("auth:login", this.onAuthLogin)
this.onAuthLogin = null
}
},
countImages(): number {

View File

@ -4,7 +4,7 @@
// 真正的 HTTP 端点(/me/history*)与单篇回查(getHistoryMeta)在 api.ts。
//
// 语义(2026-09-02 用户拍板):
// - 浏览历史 = 「点开过哪篇文章」,触发时机在 item 详情页(打开某篇走秀/街拍即记一条)。
// - 浏览历史 = 「点开过哪篇文章」,触发时机在走秀/街拍详情页(打开某篇即记一条)。
// 列表页进入不记。
// - 服务端只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串
// (首字符区分类型:s 开头=街拍 / r 开头=走秀)。
@ -77,7 +77,7 @@ function serverMode(): boolean {
// ---------- 对外 API ----------
/**
* 记录一次浏览:打开某篇文章详情时调用(见 item 详情页)。
* 记录一次浏览:打开某篇文章详情时调用(走秀/街拍详情页)。
* 登录态:写入本地缓存 + 后台 POST 服务端(upsert 刷新时间,失败不回滚本地)。
* 未登录:仅写入本地缓存。
*/

View File

@ -3,7 +3,7 @@ import { DEFAULT_LOCALE, type Locale } from './index';
/**
* 单文件翻译字典。
*
* 设计(按你的要求):
* 设计:
* - key 就是「英文原文」,直接当文案用,无需额外维护英文副本;
* - value 只存「非默认语言」的翻译(本项目默认语言是 en,所以只存 cn 等);
* - 英文环境调用 t(key) 直接返回 key 本身;
@ -80,6 +80,7 @@ export const dict: Record<string, TranslationEntry> = {
// ── 页脚 / Footer ──
'© 2026 portfolio studio. all rights reserved.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
'all rights reserved.': { cn: '保留所有权利。' },
// ── 页脚简介 / Manifesto ──
'minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.':

View File

@ -9,7 +9,7 @@ import { getLocaleByPath, getRelativeLocaleUrl } from 'astro:i18n';
import { translate } from './dictionary';
// ── 配置 / Config ──
/** 默认语言(无前缀时使用的语言,如 /shows)。 */
/** 默认语言(配置为 prefixDefaultLocale: true,故也带前缀,如 /en/shows)。 */
export const DEFAULT_LOCALE = 'en' as const;
/** 支持的语言列表。URL 前缀用的就是这些值,例如 /cn/shows 的 `cn`。 */
@ -31,7 +31,7 @@ export const LOCALE_NAMES: Record<string, { name: string; nativeName: string }>
// ── 工具 / Utils ──
/**
* 从 URL 解析语言,委托 Astro 内置的 astro:i18n.getLocaleByPath。
* 无前缀(默认语言)路径会解析回 DEFAULT_LOCALE。
* 解析失败或非受支持语言时回退 DEFAULT_LOCALE。
*/
export function getLocaleFromUrl(url: URL | string): Locale {
const pathname = typeof url === 'string' ? url : url.pathname;

View File

@ -1,7 +1,7 @@
// 全站 URL 路由集中管理
// - ROUTES:逻辑路由(不带 locale 前缀)。静态用字符串,动态用工厂函数。
// - href:服务端(.astro frontmatter)使用,跟随 astro.config 的 prefixDefaultLocale 规则拼前缀。
// - clientHref:客户端脚本(Alpine/Vue)使用,astro:i18n 在浏览器不可用,故自行拼接。
// - clientHref:客户端脚本(Alpine)使用,astro:i18n 在浏览器不可用,故自行拼接。
import { getRelativeLocaleUrl } from 'astro:i18n';
@ -16,7 +16,6 @@ export const ROUTES = {
// 动态路由(工厂函数)。
// 走秀 / 街拍详情按内容类型拆分到独立路由,与列表页 slug 保持一致;
// 前端按类型化路由分流到对应后端强类型接口(/runway-looks/:id、/street-snaps/:id)。
// 旧 /item/[id] 仅保留为 301 重定向(见 src/pages/{locale}/item/[id].astro),按 id 前缀回退。
runwayLook: (id: string | number) => `/runway-looks/${id}`,
streetSnap: (id: string | number) => `/street-snaps/${id}`,
} as const;

View File

@ -1,5 +1,18 @@
@import "tailwindcss";
/* 中性灰阶令牌:统一全站散落的 #f1f1f1/#efefef/#e6e6e6/#ccc/#bdbdbd/#999/#888/#666 等 */
:root {
--c-gray-50: #f6f6f6;
--c-gray-100: #f1f1f1;
--c-gray-200: #efefef;
--c-gray-300: #e6e6e6;
--c-gray-400: #ccc;
--c-gray-500: #bdbdbd;
--c-gray-600: #999;
--c-gray-700: #888;
--c-gray-800: #666;
}
[x-cloak] { display: none !important; }
/* ===== 共享原子:被多个组件复用的极小样式片段 =====
@ -25,4 +38,15 @@
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* 键盘聚焦可见性:所有可交互元素加黑色描边环,提升无障碍与精致度
(hover:opacity 只覆盖鼠标态,键盘 Tab 用户需要明确的焦点指示) */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
[role="button"]:focus-visible {
outline: 2px solid #111;
outline-offset: 2px;
}

View File

@ -5,8 +5,6 @@
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* ====================== 顶部 / 主体布局 ====================== */
.showsroot,
.snapsroot {
@ -27,8 +25,8 @@
}
/* 已激活筛选 pills */
.showspills { @apply flex flex-wrap gap-2 pt-3.5; }
.snapspills { @apply flex flex-wrap gap-2 mb-4; }
.showspills { @apply flex flex-wrap gap-2 mt-4; }
.snapspills { @apply flex flex-wrap gap-2 mt-4; }
.showspill,
.snapspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
@ -49,7 +47,7 @@
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.showsaside {
@apply rounded-none transition-[flex-basis] duration-250 ease-in-out;
@apply rounded-none transition-[flex-basis] duration-[250ms] ease-in-out;
}
.showsaside-head,
.snapsaside-head {
@ -61,13 +59,13 @@
}
.showsaside-body,
.snapsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:var(--c-gray-400)_#fff];
}
/* 筛选分组 */
.showsg,
.snapsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
@apply border-t border-[var(--c-gray-300)] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.showsg-h,
.snapsg-h {
@ -78,7 +76,7 @@
}
.showsg-chev,
.snapsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
@apply inline-block text-[9px] leading-none text-[var(--c-gray-600)] transition-transform duration-200 ml-1;
}
.showsg-chev.is-collapsed,
.snapsg-chev.is-collapsed { @apply -rotate-90; }
@ -90,7 +88,7 @@
}
.showschecks li,
.snapschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[var(--c-gray-50)];
}
.showschecks input[type="radio"],
.snapschecks input[type="radio"] {
@ -114,7 +112,7 @@
}
.showsclear,
.snapsclear {
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150;
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-800)] cursor-pointer hover:text-black transition-colors duration-150;
}
/* ====================== 右 网格 / 卡片 ====================== */
@ -124,7 +122,7 @@
.showsgrid,
.snapsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
@apply grid grid-cols-2 gap-[22px] relative max-sm:grid-cols-1;
}
.showsgrid.is-loading::before,
.snapscontent.is-loading::before {
@ -135,29 +133,29 @@
.showscard,
.snapscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-md:flex-col max-md:gap-[10px];
}
.showscard:hover .showscard-img img,
.snapscard:hover .snapscard-img img { @apply scale-104; }
.snapscard:hover .snapscard-img img { @apply scale-[1.04]; }
.showscard-left,
.snapscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
@apply flex-none w-[30%] min-w-[110px] max-md:w-full max-md:flex-none;
}
.showscard-img,
.snapscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
@apply relative w-full aspect-[3/4] bg-[var(--c-gray-100)] overflow-hidden rounded-none;
}
.showscard-img img,
.snapscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
@apply w-full h-full object-cover transition-transform duration-[400ms] opacity-0;
}
.showscard-img img.is-loaded,
.snapscard-img img.is-loaded { @apply opacity-100; }
.showsspin,
.snapsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
@apply absolute inset-0 flex items-center justify-center text-[var(--c-gray-500)] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.showscard-img.is-loaded .showsspin,
.snapscard-img.is-loaded .snapsspin,
@ -166,7 +164,7 @@
.showscard-right,
.snapscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
@apply flex-1 min-w-0 flex flex-col justify-between max-md:w-full;
}
.showscard-info,
.snapscard-info {
@ -178,7 +176,7 @@
}
.showscard-meta,
.snapscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
@apply text-[9.5px] text-[var(--c-gray-700)] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.showscard-strip,
@ -187,16 +185,16 @@
}
.showscard-thumb,
.snapscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
@apply relative aspect-[3/4] bg-[var(--c-gray-100)] overflow-hidden;
}
.showscard-thumb img,
.snapscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
@apply w-full h-full object-cover opacity-0 transition-opacity duration-[450ms] ease-in-out;
}
.showscard-thumb img.is-loaded,
.snapscard-thumb img.is-loaded { @apply opacity-100; }
.showscard-thumb.placeholder,
.snapscard-thumb.placeholder { @apply bg-[#f6f6f6]; }
.snapscard-thumb.placeholder { @apply bg-[var(--c-gray-50)]; }
.showscard-thumb-more {
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
}
@ -208,7 +206,7 @@
.snapscard--skel .snapscard-img,
.snapscard--skel .snapscard-thumb,
.snapscard--skel .snapsline {
@apply bg-[#efefef];
@apply bg-[var(--c-gray-200)];
animation: looksShim 1.6s ease infinite;
}
@ -220,12 +218,12 @@
}
.pg.is-current { @apply bg-black text-white cursor-default; }
.pg:disabled { @apply opacity-30 cursor-default; }
.pg-ellipsis { @apply px-1 text-[#999] select-none text-[11px]; }
.pg-ellipsis { @apply px-1 text-[var(--c-gray-600)] select-none text-[11px]; }
.pg-wrap { @apply inline-flex items-center; }
.showsempty,
.snapsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[var(--c-gray-600)] border border-dashed border-[var(--c-gray-400)];
}
@keyframes looksProg { 0% { left: -38%; } 100% { left: 100%; } }