update
This commit is contained in:
@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
|
||||
// 全站统一登录门禁,两种语义属性(按使用场景二选一,不要混用):
|
||||
// - login-required:需要登录的「动作」(收藏/翻页/看完整图…)。点击时若未登录 →
|
||||
@ -69,9 +69,25 @@ const fullLoginLabel = t('go to full login')
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
|
||||
// 全局登录 store 的数据形状。
|
||||
// 必须显式声明并注解:@types/alpinejs 的 Stores 是 `[key: string | symbol]: unknown`,
|
||||
// 内联字面量会失去上下文类型,导致各方法内 this 退化成 {}(原先 16 处报错)。
|
||||
interface AuthStore {
|
||||
isOpen: boolean
|
||||
pending: HTMLElement | null
|
||||
account: string
|
||||
password: string
|
||||
error: string
|
||||
loading: boolean
|
||||
request(el: HTMLElement): boolean
|
||||
close(): void
|
||||
submit(): Promise<void>
|
||||
goFullLogin(): void
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
// 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层
|
||||
Alpine.store("auth", {
|
||||
const authStore: AuthStore = {
|
||||
isOpen: false,
|
||||
pending: null as HTMLElement | null,
|
||||
account: "",
|
||||
@ -126,7 +142,9 @@ const fullLoginLabel = t('go to full login')
|
||||
this.close()
|
||||
window.location.assign(`/${loc}/login?redirect=${encodeURIComponent(p)}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Alpine.store("auth", authStore)
|
||||
|
||||
// 全站统一登录门禁:两种语义属性(capture 阶段,先于元素自身 @click 处理)
|
||||
// login-required → 就地弹窗(登录后自动重放原点击)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getI18n } from "@/i18n/utils";
|
||||
import { getI18n } from "@/lib/i18n";
|
||||
|
||||
interface Crumb {
|
||||
/** i18n key:英文原文,如 'Home' / 'Runway Archive';中文环境自动取字典翻译 */
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
|
||||
interface Props {
|
||||
|
||||
179
src/components/GalleryLightbox.astro
Normal file
179
src/components/GalleryLightbox.astro
Normal file
@ -0,0 +1,179 @@
|
||||
---
|
||||
// 走秀 / 街拍详情页共享的全屏大图灯箱:左栏缩略图条 + 大图 + 上/下·左/右翻页 + 单图收藏。
|
||||
//
|
||||
// 视觉遵循全站「黑白编辑风」:直角细框、mono 小字 + 宽字距、零填充计数、hover 反色(bg-black + 白字)。
|
||||
// 交互逻辑全部在 src/lib/gallery.ts(createGalleryView);本组件只负责标记。
|
||||
//
|
||||
// 各页独有的附加层通过具名插槽 details 注入(走秀页塞「当前 look 的细节图簇」,街拍页不传)。
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
|
||||
interface Props {
|
||||
/** SSR 首屏图片(前 PREVIEW_LIMIT 张),需带 thumb 供左栏缩略图条使用 */
|
||||
images: { thumb: string }[]
|
||||
/** 缩略图比例:走秀 look 为 2/3,街拍为 3/4 */
|
||||
aspect?: "2/3" | "3/4"
|
||||
}
|
||||
const { images, aspect = "2/3" } = Astro.props
|
||||
const { t } = getI18n(Astro)
|
||||
|
||||
// 两个字面量都出现在本文件源码里,保证 Tailwind v4 JIT 能扫描到
|
||||
const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
|
||||
---
|
||||
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@keydown.window.escape="lbOpen && closeLb()"
|
||||
@keydown.window.arrow-right="lbOpen && nextLb()"
|
||||
@keydown.window.arrow-left="lbOpen && prevLb()"
|
||||
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 计数:mono + 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 font-mono text-[11px] tracking-[0.22em] text-[#111] backdrop-blur-md"
|
||||
x-text="`${String(lbIndex + 1).padStart(2, '0')} / ${String(imageCount).padStart(2, '0')}`"
|
||||
></div>
|
||||
|
||||
{/* 单图收藏:圆形,与列表卡片 .fav-toggle / 详情页右下角 #fav-fab 的全站「收藏=圆形」语义保持一致 */}
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentFavId(), currentFavUrl())"
|
||||
:class="isFav(currentFavId()) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="closeLb()"
|
||||
class="absolute top-5 right-5 z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Close')}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||
<div
|
||||
id="lb-thumbs"
|
||||
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 no-scrollbar"
|
||||
>
|
||||
{images.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
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`}
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
aria-label={`${i + 1}`}
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<button
|
||||
type="button"
|
||||
: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`}
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
:aria-label="String(5 + i + 1)"
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M6 14l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M6 10l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="lb-main"
|
||||
@click.self="closeLb()"
|
||||
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||||
x-bind:class="lbZoomed
|
||||
? 'overflow-y-auto overflow-x-hidden items-start'
|
||||
: 'items-center'"
|
||||
>
|
||||
<img
|
||||
id="lb-image"
|
||||
src=""
|
||||
alt=""
|
||||
@click="toggleZoom()"
|
||||
decoding="async"
|
||||
class="block select-none transition-opacity duration-200"
|
||||
x-bind:class="lbZoomed
|
||||
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
|
||||
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-0 right-0 bottom-11 z-20 px-6 text-center font-mono text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
|
||||
x-text="currentImageName"
|
||||
></div>
|
||||
|
||||
<div
|
||||
x-show="!lbZoomed"
|
||||
class="absolute left-0 right-0 bottom-4 z-20 px-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-black/40 pointer-events-none transition-opacity duration-200"
|
||||
>
|
||||
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||||
</div>
|
||||
|
||||
{/* 各页独有的附加层(走秀页注入「当前 look 的细节图簇」;街拍页不传) */}
|
||||
<slot name="details" />
|
||||
</div>
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
import { getSsgIndexRunway, getSsgStreetSnapPopular } from "@/lib/api"
|
||||
import { getSsgIndexRunway, getSsgStreetSnapPopular } from "@/lib/ssg"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
@ -516,7 +516,7 @@ const streetItems: {
|
||||
import "swiper/css/free-mode"
|
||||
|
||||
/* ---------------- Swiper 实例管理 ---------------- */
|
||||
let swipers = []
|
||||
let swipers: Swiper[] = []
|
||||
let updateScheduled = false
|
||||
|
||||
// RAF 合并:同一帧内多张图片加载完成 / 多个 resize,只执行一次 swiper.update()
|
||||
@ -542,9 +542,9 @@ const streetItems: {
|
||||
function initSwipers() {
|
||||
destroySwipers()
|
||||
document.querySelectorAll(".hs-swiper").forEach((el) => {
|
||||
const prevBtn = el.parentElement?.querySelector(".hs-prev")
|
||||
const nextBtn = el.parentElement?.querySelector(".hs-next")
|
||||
const swiper = new Swiper(el, {
|
||||
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev") ?? null
|
||||
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next") ?? null
|
||||
const swiper = new Swiper(el as HTMLElement, {
|
||||
modules: [Navigation, FreeMode, Keyboard],
|
||||
slidesPerView: "auto",
|
||||
spaceBetween: 4,
|
||||
@ -580,7 +580,7 @@ const streetItems: {
|
||||
|
||||
// Tab 切换后重算尺寸(事件委托,兼容 View Transitions 后的新 DOM)
|
||||
document.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".tab")) {
|
||||
if ((e.target as Element | null)?.closest(".tab")) {
|
||||
setTimeout(scheduleSwiperUpdate, 60)
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
---
|
||||
import type { RawArticle } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrBase } from "@/lib/request"
|
||||
import { ssrAbs } from "@/lib/request"
|
||||
|
||||
interface Props {
|
||||
article: RawArticle | null
|
||||
@ -14,21 +15,15 @@ interface Props {
|
||||
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const full = (u: string): string => {
|
||||
if (!u) return ""
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
return ssrBase().replace(/\/$/, "") + u
|
||||
}
|
||||
|
||||
// 改造后:后端详情接口 images 仅返回主图(look 图),细节图在 details 分组,需经「细节入口」展开。
|
||||
// 故此处 images 即为主图集合,默认只渲染它们;每张主图带 look_index 用于关联其细节图。
|
||||
const images = (article?.images ?? [])
|
||||
.map((im) => ({
|
||||
id: im.id,
|
||||
url: full(im.image || ""),
|
||||
url: ssrAbs(im.image || ""),
|
||||
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb(240px 样式)即可,
|
||||
// 不必拿 720px 的展示图当缩略图(一场 63 个 look 能省约 1MB)。后端未返回 thumb 时回落展示图。
|
||||
thumb: full(im.thumb || im.image || ""),
|
||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||
name: im.name || "",
|
||||
lookIndex: Number(im.look_index ?? 0),
|
||||
@ -292,164 +287,15 @@ const homeHref = href(locale, ROUTES.home)
|
||||
)
|
||||
}
|
||||
|
||||
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
|
||||
走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
|
||||
{article && previewImages.length > 0 && (
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@keydown.window.escape="lbOpen && closeLb()"
|
||||
@keydown.window.arrow-right="lbOpen && nextLb()"
|
||||
@keydown.window.arrow-left="lbOpen && prevLb()"
|
||||
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 计数:mono + 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 font-mono text-[11px] tracking-[0.22em] text-[#111] backdrop-blur-md"
|
||||
x-text="`${String(lbIndex + 1).padStart(2, '0')} / ${String(imageCount).padStart(2, '0')}`"
|
||||
></div>
|
||||
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
||||
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="closeLb()"
|
||||
class="absolute top-5 right-5 z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Close')}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||
<div
|
||||
id="lb-thumbs"
|
||||
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 no-scrollbar"
|
||||
>
|
||||
{previewImages.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lb-index={i}
|
||||
data-thumb-url={im.thumb}
|
||||
@click={`showLb(${i})`}
|
||||
class="lb-thumb relative block w-full aspect-[2/3] mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center"
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
aria-label={`${i + 1}`}
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<button
|
||||
type="button"
|
||||
:data-lb-index="5 + i"
|
||||
:data-thumb-url="im.thumb"
|
||||
@click="showLb(5 + i)"
|
||||
class="lb-thumb relative block w-full aspect-[2/3] mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center"
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
:aria-label="String(5 + i + 1)"
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M6 14l6-6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M6 10l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="lb-main"
|
||||
@click.self="closeLb()"
|
||||
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||||
x-bind:class="lbZoomed
|
||||
? 'overflow-y-auto overflow-x-hidden items-start'
|
||||
: 'items-center'"
|
||||
>
|
||||
<img
|
||||
id="lb-image"
|
||||
src=""
|
||||
alt=""
|
||||
@click="toggleZoom()"
|
||||
decoding="async"
|
||||
class="block select-none transition-opacity duration-200"
|
||||
x-bind:class="lbZoomed
|
||||
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
|
||||
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-0 right-0 bottom-11 z-20 px-6 text-center font-mono text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
|
||||
x-text="currentImageName"
|
||||
></div>
|
||||
|
||||
<div
|
||||
x-show="!lbZoomed"
|
||||
class="absolute left-0 right-0 bottom-4 z-20 px-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-black/40 pointer-events-none transition-opacity duration-200"
|
||||
>
|
||||
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||||
</div>
|
||||
|
||||
<GalleryLightbox images={previewImages} aspect="2/3">
|
||||
{/* 当前 look 的细节图:右下角一小簇缩略图(竖版主图右下角本就是空白,不遮挡主图)。
|
||||
主图放大(lbZoomed)时整簇隐藏——放大态要专注当前大图;点其中一张进细节灯箱看大图。
|
||||
单个 look 最多 4 张细节图(多数 1~2 张),一排横放即可,不设滚动条。 */}
|
||||
<div
|
||||
slot="details"
|
||||
id="lb-details"
|
||||
x-show="!lbZoomed && currentDetailList.length > 0"
|
||||
x-cloak
|
||||
@ -469,7 +315,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GalleryLightbox>
|
||||
)}
|
||||
|
||||
{/* ===== 细节图灯箱:点大图灯箱里的细节缩略图打开,单张细节图放大查看 ===== */}
|
||||
@ -493,7 +339,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentDetailFavId, currentDetailFavUrl)"
|
||||
:class="isFav(currentDetailFavId) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
@ -586,41 +432,73 @@ const homeHref = href(locale, ROUTES.home)
|
||||
|
||||
<script>
|
||||
import { getArticleDetailAuthed } from "@/lib/api"
|
||||
import { createGalleryView } from "@/lib/gallery"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
|
||||
|
||||
interface DetailImage {
|
||||
id: string
|
||||
url: string
|
||||
thumb: string
|
||||
raw: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// 走秀页在大图灯箱之上叠加的部分:每行张数(density)+ 细节图灯箱。
|
||||
//
|
||||
// 为什么要把这个类型写出来:`Alpine.data` 的泛型 T 靠**对象字面量**推断,而这里用了对象展开
|
||||
// ({...createGalleryView()}),推断会退化成 {},于是方法里的 this 只剩 Alpine 魔法属性,
|
||||
// 所有 this.xxx 都报「属性不存在」。给回调标注返回类型,T 才有确定值。
|
||||
// (@types/alpinejs 那个自引用泛型约束的老毛病,详见 MEMORY.md。)
|
||||
type ArticleExtras = {
|
||||
density: number
|
||||
currentDetailName: string
|
||||
detailLbOpen: boolean
|
||||
detailLbIndex: number
|
||||
detailLbZoomed: boolean
|
||||
detailImages: DetailImage[]
|
||||
detailImagesByLook: Record<number, DetailImage[]>
|
||||
detailLbFromLb: boolean
|
||||
lbReturnIndex: number
|
||||
init(): void
|
||||
gridClass(): string
|
||||
getImageLookIndex(index: number): number
|
||||
currentDetailList: DetailImage[]
|
||||
openDetailLbAt(idx: number): void
|
||||
buildDetailMap(): void
|
||||
detailCount(li: number): number
|
||||
openDetailLb(li: number, startIndex?: number, fromLb?: boolean): void
|
||||
closeDetailLb(): void
|
||||
showDetailLb(index: number): void
|
||||
nextDetailLb(): void
|
||||
prevDetailLb(): void
|
||||
normalizeDetailIndex(index: number): number
|
||||
updateDetailImage(): void
|
||||
detailImageUrl(index: number): string
|
||||
detailImageName(index: number): string
|
||||
currentDetailFavId: string
|
||||
currentDetailFavUrl: string
|
||||
toggleDetailZoom(): void
|
||||
scrollDetailThumbIntoView(): void
|
||||
preloadDetailAround(): void
|
||||
}
|
||||
|
||||
// 图集 + 大图灯箱的共享逻辑在 lib/gallery.ts;本组件只叠加走秀独有的「每行张数」与「细节图灯箱」。
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("articleView", () => ({
|
||||
lbOpen: false,
|
||||
lbIndex: 0,
|
||||
lbZoomed: false,
|
||||
imageCount: 0,
|
||||
currentImageName: "",
|
||||
currentDetailName: "",
|
||||
|
||||
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
|
||||
favIds: [],
|
||||
favType: "runway_image",
|
||||
galleryUid: "",
|
||||
galleryTitle: "",
|
||||
galleryBrand: "",
|
||||
|
||||
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
|
||||
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
|
||||
isAuthed: false,
|
||||
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
|
||||
preview: false,
|
||||
// 已登录态图集级(gallery,r=/s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
|
||||
galleryFav: false,
|
||||
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
|
||||
extraImages: [] as { id: string; url: string; thumb: string; raw: string; name: string; lookIndex?: number }[],
|
||||
Alpine.data(
|
||||
"articleView",
|
||||
(): ReturnType<typeof createGalleryView> & ArticleExtras => ({
|
||||
...createGalleryView({
|
||||
rootSelector: "#article-view",
|
||||
gallerySelector: "#article-gallery",
|
||||
favType: "runway_image",
|
||||
fetchDetail: getArticleDetailAuthed,
|
||||
}),
|
||||
|
||||
// 每行张数(应对图集图片很多):只有 3 / 5 两档(工具栏图标按钮切换),移动端固定 2 列。
|
||||
// 默认 5 张(进入图集详情页即展示 5 列网格,紧凑浏览)。
|
||||
density: 5,
|
||||
|
||||
// 细节图灯箱:点大图灯箱里的细节缩略图打开,展示该 look 的细节图集合。
|
||||
currentDetailName: "",
|
||||
detailLbOpen: false,
|
||||
detailLbIndex: 0,
|
||||
detailLbZoomed: false,
|
||||
@ -633,119 +511,16 @@ const homeHref = href(locale, ROUTES.home)
|
||||
lbReturnIndex: 0,
|
||||
|
||||
init() {
|
||||
this.favType = this.$el.dataset.favType || "runway_image"
|
||||
this.galleryUid = this.$el.dataset.galleryUid || ""
|
||||
this.galleryTitle = this.$el.dataset.galleryTitle || ""
|
||||
this.galleryBrand = this.$el.dataset.galleryBrand || ""
|
||||
this.preview = this.$el.dataset.preview === "true"
|
||||
this.isAuthed = !!getUser()
|
||||
|
||||
// 解析后端返回的 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
|
||||
// 本组件独有:解析后端 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
|
||||
this.buildDetailMap()
|
||||
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#article-gallery [data-image-index]"
|
||||
).length
|
||||
|
||||
this.initThumbLazyLoading()
|
||||
this.initImageFavs()
|
||||
this.maybeLocateImageFromUrl()
|
||||
|
||||
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
|
||||
if (getUser()) this.loadAuthedState()
|
||||
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
|
||||
window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
|
||||
// 共享初始化(登录态、图片计数、缩略图懒加载、收藏态、深链定位、登录后补全量)
|
||||
this.galleryInit()
|
||||
},
|
||||
|
||||
// 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
|
||||
// gallery 级(r=)收藏态广播给页面右下角 fav-fab;image 级(i=)收藏态直接重建 favIds。
|
||||
async loadAuthedState() {
|
||||
if (!getUser()) return
|
||||
try {
|
||||
const full = await getArticleDetailAuthed(this.galleryUid)
|
||||
if (!full || !Array.isArray(full.images)) return
|
||||
|
||||
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
|
||||
this.galleryFav = !!full.favorited
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
|
||||
)
|
||||
|
||||
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
|
||||
const favIds: string[] = []
|
||||
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
|
||||
|
||||
// 超预览上限才追加第 6 张起图片(前 5 张已由 SSR 渲染,仅补收藏态)
|
||||
if (this.preview) {
|
||||
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
|
||||
id: im.id || "",
|
||||
url: toAbs(im.image || ""),
|
||||
thumb: toAbs(im.thumb || im.image || ""),
|
||||
raw: im.image || "",
|
||||
name: im.name || "",
|
||||
lookIndex: Number(im.look_index ?? 0),
|
||||
}))
|
||||
await this.$nextTick()
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#article-gallery [data-image-index]"
|
||||
).length
|
||||
;(full.images || []).slice(5).forEach(mark)
|
||||
this.initThumbLazyLoading()
|
||||
this.maybeLocateImageFromUrl()
|
||||
}
|
||||
// 前 5 张收藏态
|
||||
;(full.images || []).slice(0, 5).forEach(mark)
|
||||
this.favIds = favIds
|
||||
} catch {
|
||||
/* 失败维持本地/截断态,门禁仍在 */
|
||||
}
|
||||
},
|
||||
|
||||
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||||
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
||||
maybeLocateImageFromUrl() {
|
||||
if (typeof window === "undefined") return
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const want = q.get("img")
|
||||
if (!want) return
|
||||
const figures = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
|
||||
)
|
||||
const idx = figures.findIndex((el) => el.dataset.favId === want)
|
||||
if (idx < 0) return
|
||||
const fig = figures[idx] as HTMLElement
|
||||
requestAnimationFrame(() => {
|
||||
if (q.get("lb") === "1") {
|
||||
this.openLb(idx)
|
||||
return
|
||||
}
|
||||
fig.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
fig.classList.add("locate-flash")
|
||||
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
|
||||
})
|
||||
},
|
||||
|
||||
getImageElement(index: number) {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`#article-gallery [data-image-index="${index}"]`
|
||||
)
|
||||
},
|
||||
|
||||
getImageUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageUrl || ""
|
||||
},
|
||||
|
||||
getImageName(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageName || ""
|
||||
},
|
||||
|
||||
getImageFavId(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favId || ""
|
||||
},
|
||||
|
||||
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
|
||||
getImageFavUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favUrl || ""
|
||||
// ===== 每行张数(密度) =====
|
||||
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
|
||||
gridClass() {
|
||||
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
|
||||
},
|
||||
|
||||
// 读第 index 张大图所属的主图序号(look_index),来自 figure 上的 data-look-index。
|
||||
@ -775,183 +550,11 @@ const homeHref = href(locale, ROUTES.home)
|
||||
this.openDetailLb(li, idx, true)
|
||||
},
|
||||
|
||||
// ===== 单图收藏 =====
|
||||
// 只把「当前这篇图集的全部图片 id」发给后端问哪些已收藏,
|
||||
// 复杂度与图片数成正比,与用户收藏总量无关。
|
||||
allImageFavIds(): string[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
|
||||
)
|
||||
.map((el) => el.dataset.favId || "")
|
||||
.filter(Boolean)
|
||||
},
|
||||
|
||||
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
|
||||
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
|
||||
initImageFavs() {
|
||||
const ids = this.allImageFavIds()
|
||||
if (!ids.length) return
|
||||
this.favIds = ids.filter((id) => isFavorited(id))
|
||||
},
|
||||
|
||||
isFav(id: string) {
|
||||
return !!id && this.favIds.includes(id)
|
||||
},
|
||||
|
||||
toggleFav(id: string, url: string) {
|
||||
if (!id) return
|
||||
toggleImageFavorite({
|
||||
imageId: id,
|
||||
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
||||
imageUrl: url,
|
||||
parentUid: this.galleryUid,
|
||||
title: this.galleryTitle,
|
||||
brand: this.galleryBrand,
|
||||
})
|
||||
this.favIds = this.isFav(id)
|
||||
? this.favIds.filter((x: string) => x !== id)
|
||||
: [...this.favIds, id]
|
||||
},
|
||||
|
||||
// 灯箱里的收藏按钮跟随当前大图
|
||||
get currentFavId() {
|
||||
return this.getImageFavId(this.lbIndex)
|
||||
},
|
||||
|
||||
get currentFavUrl() {
|
||||
return this.getImageFavUrl(this.lbIndex)
|
||||
},
|
||||
|
||||
openLb(index = 0) {
|
||||
if (!this.imageCount) return
|
||||
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
this.lbOpen = true
|
||||
|
||||
document.body.style.overflow = "hidden"
|
||||
|
||||
this.updateLightboxImage()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
|
||||
closeLb() {
|
||||
this.lbOpen = false
|
||||
this.lbZoomed = false
|
||||
document.body.style.overflow = ""
|
||||
},
|
||||
|
||||
showLb(index: number) {
|
||||
if (!this.imageCount) return
|
||||
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
|
||||
this.updateLightboxImage()
|
||||
|
||||
const main = document.getElementById("lb-main")
|
||||
|
||||
if (main) {
|
||||
main.scrollTop = 0
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
|
||||
nextLb() {
|
||||
this.showLb(this.lbIndex + 1)
|
||||
},
|
||||
|
||||
prevLb() {
|
||||
this.showLb(this.lbIndex - 1)
|
||||
},
|
||||
|
||||
normalizeIndex(index: number) {
|
||||
if (!this.imageCount) return 0
|
||||
return ((index % this.imageCount) + this.imageCount) % this.imageCount
|
||||
},
|
||||
|
||||
updateLightboxImage() {
|
||||
const image = document.getElementById("lb-image") as HTMLImageElement | null
|
||||
|
||||
if (!image) return
|
||||
|
||||
const url = this.getImageUrl(this.lbIndex)
|
||||
const name = this.getImageName(this.lbIndex)
|
||||
|
||||
image.src = url
|
||||
image.alt = name
|
||||
this.currentImageName = name
|
||||
},
|
||||
|
||||
toggleZoom() {
|
||||
this.lbZoomed = !this.lbZoomed
|
||||
|
||||
const main = document.getElementById("lb-main")
|
||||
|
||||
if (main) {
|
||||
main.scrollTop = 0
|
||||
}
|
||||
},
|
||||
|
||||
scrollThumbIntoView() {
|
||||
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
|
||||
|
||||
if (!thumbs) return
|
||||
|
||||
const active = thumbs.querySelector<HTMLElement>(
|
||||
`[data-lb-index="${this.lbIndex}"]`
|
||||
)
|
||||
|
||||
if (!active) return
|
||||
|
||||
const target =
|
||||
active.offsetTop -
|
||||
(thumbs.clientHeight - active.clientHeight) / 2
|
||||
|
||||
thumbs.scrollTo({
|
||||
top: Math.max(0, target),
|
||||
behavior: "smooth",
|
||||
})
|
||||
},
|
||||
|
||||
preloadAround() {
|
||||
const indexes = [
|
||||
this.lbIndex,
|
||||
this.normalizeIndex(this.lbIndex - 1),
|
||||
this.normalizeIndex(this.lbIndex + 1),
|
||||
]
|
||||
|
||||
indexes.forEach((index: number) => {
|
||||
const url = this.getImageUrl(index)
|
||||
|
||||
if (!url) return
|
||||
|
||||
const image = new Image()
|
||||
image.decoding = "async"
|
||||
image.src = url
|
||||
})
|
||||
},
|
||||
|
||||
// ===== 每行张数(密度) =====
|
||||
// 只有 3 / 5 两档(工具栏图标按钮切换);移动端固定 2 列(5 列在手机上不可用)。
|
||||
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
|
||||
gridClass() {
|
||||
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
|
||||
},
|
||||
|
||||
// ===== 细节图分组 =====
|
||||
// 由根节点 data-details(后端按 look_index 聚合的细节图 JSON)构建 look_index -> 图片数组映射。
|
||||
buildDetailMap() {
|
||||
try {
|
||||
const raw = this.$el.dataset.details
|
||||
const raw = this.root()?.dataset.details
|
||||
const arr: any[] = raw ? JSON.parse(raw) : []
|
||||
const map: Record<number, { id: string; url: string; thumb: string; raw: string; name: string }[]> = {}
|
||||
for (const d of arr) {
|
||||
@ -1022,8 +625,12 @@ const homeHref = href(locale, ROUTES.home)
|
||||
})
|
||||
},
|
||||
|
||||
nextDetailLb() { this.showDetailLb(this.detailLbIndex + 1) },
|
||||
prevDetailLb() { this.showDetailLb(this.detailLbIndex - 1) },
|
||||
nextDetailLb() {
|
||||
this.showDetailLb(this.detailLbIndex + 1)
|
||||
},
|
||||
prevDetailLb() {
|
||||
this.showDetailLb(this.detailLbIndex - 1)
|
||||
},
|
||||
|
||||
normalizeDetailIndex(index: number) {
|
||||
const n = this.detailImages.length
|
||||
@ -1047,8 +654,12 @@ const homeHref = href(locale, ROUTES.home)
|
||||
return this.detailImages[index]?.name || ""
|
||||
},
|
||||
|
||||
get currentDetailFavId() { return this.detailImages[this.detailLbIndex]?.id || "" },
|
||||
get currentDetailFavUrl() { return this.detailImages[this.detailLbIndex]?.raw || "" },
|
||||
get currentDetailFavId() {
|
||||
return this.detailImages[this.detailLbIndex]?.id || ""
|
||||
},
|
||||
get currentDetailFavUrl() {
|
||||
return this.detailImages[this.detailLbIndex]?.raw || ""
|
||||
},
|
||||
|
||||
toggleDetailZoom() {
|
||||
this.detailLbZoomed = !this.detailLbZoomed
|
||||
@ -1072,7 +683,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
this.normalizeDetailIndex(this.detailLbIndex - 1),
|
||||
this.normalizeDetailIndex(this.detailLbIndex + 1),
|
||||
]
|
||||
indexes.forEach((index: number) => {
|
||||
indexes.forEach((index) => {
|
||||
const url = this.detailImageUrl(index)
|
||||
if (!url) return
|
||||
const image = new Image()
|
||||
@ -1080,82 +691,7 @@ const homeHref = href(locale, ROUTES.home)
|
||||
image.src = url
|
||||
})
|
||||
},
|
||||
|
||||
// TODO 这是干啥的
|
||||
initThumbLazyLoading() {
|
||||
const thumbs = document.querySelectorAll<HTMLElement>(
|
||||
"#lb-thumbs [data-thumb-url]"
|
||||
)
|
||||
|
||||
if (!thumbs.length) return
|
||||
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
thumbs.forEach((thumb) => {
|
||||
thumb.style.backgroundImage =
|
||||
`url("${thumb.dataset.thumbUrl}")`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return
|
||||
|
||||
const element = entry.target as HTMLElement
|
||||
const url = element.dataset.thumbUrl
|
||||
|
||||
if (url) {
|
||||
element.style.backgroundImage = `url("${url}")`
|
||||
}
|
||||
|
||||
obs.unobserve(element)
|
||||
})
|
||||
},
|
||||
{
|
||||
root: document.getElementById("lb-thumbs"),
|
||||
rootMargin: "300px",
|
||||
}
|
||||
)
|
||||
|
||||
thumbs.forEach((thumb) => observer.observe(thumb))
|
||||
},
|
||||
}))
|
||||
}),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 单图收藏爱心:默认描边,已收藏实心 */
|
||||
.fav-btn svg {
|
||||
fill: none;
|
||||
}
|
||||
.fav-btn.is-fav svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
|
||||
.locate-flash {
|
||||
outline: 3px solid #111;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
|
||||
@media (hover: none) {
|
||||
.fav-btn {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -3,12 +3,17 @@
|
||||
// 轻量原则(用户要求"朴实流畅"):
|
||||
// - 只用静态大字 + mono 小标 + 一条 2px 细线进度(transform/opacity 动画,最便宜);
|
||||
// - 去掉昂贵的多层 text-shadow 位移动画与外部字体;最短展示时间压缩到 ~350ms,跟手不拖沓。
|
||||
|
||||
import { config } from '@/lib/config';
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
|
||||
const { t } = getI18n(Astro)
|
||||
---
|
||||
|
||||
<div id="loading-screen" aria-hidden="true">
|
||||
<div id="ls-box">
|
||||
<h1>Portfolio Studio</h1>
|
||||
<p class="ls-sub">Loading</p>
|
||||
<h1>{config.siteName}</h1>
|
||||
<p class="ls-sub">{t('loading')}</p>
|
||||
<div class="ls-bar" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -88,15 +93,16 @@
|
||||
<script>
|
||||
// load 后淡出;最少展示 350ms 防闪烁,最多 2s 兜底。
|
||||
(function () {
|
||||
var el = document.getElementById('loading-screen');
|
||||
const el = document.getElementById('loading-screen');
|
||||
if (!el) return;
|
||||
const screen = el; // 已收窄为非空;闭包内 TS 不保留对 el 的收窄,故另存一份
|
||||
var hidden = false;
|
||||
function hide() {
|
||||
if (hidden) return;
|
||||
hidden = true;
|
||||
el.classList.add('is-hidden');
|
||||
screen.classList.add('is-hidden');
|
||||
setTimeout(function () {
|
||||
el.style.display = 'none';
|
||||
screen.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
var start = Date.now();
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
---
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import BrandModal from "@/components/BrandModal.astro"
|
||||
import { getSsgHotBrands } from "@/lib/api"
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { getSsgHotBrands } from "@/lib/ssg"
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import "@/styles/look-grid.css"
|
||||
|
||||
|
||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
// 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。
|
||||
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
|
||||
@ -260,7 +260,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
</div>
|
||||
|
||||
|
||||
<script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
|
||||
<script is:inline id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
|
||||
|
||||
|
||||
|
||||
@ -279,7 +279,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
|
||||
return {
|
||||
locale: '',
|
||||
hotBrands,
|
||||
hotBrands: hotBrands as { id: string; name: string }[],
|
||||
SPIN_SVG,
|
||||
// 侧栏分组折叠状态:默认全部收起(不做 localStorage 持久化,每次进页都是关着的)。
|
||||
collapsedGroups: {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
---
|
||||
import type { RawStreetSnap } from "@/lib/api"
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrBase } from "@/lib/request"
|
||||
import { ssrAbs } from "@/lib/request"
|
||||
|
||||
interface Props {
|
||||
snap: RawStreetSnap | null
|
||||
@ -15,16 +16,12 @@ interface Props {
|
||||
const { snap, error = null, preview = false, imageTotal = 0 } = Astro.props
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const full = (u: string): string => {
|
||||
if (!u) return ""
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
return ssrBase().replace(/\/$/, "") + u
|
||||
}
|
||||
|
||||
const images = (snap?.images ?? [])
|
||||
.map((im) => ({
|
||||
id: im.id,
|
||||
url: full(im.image || ""),
|
||||
url: ssrAbs(im.image || ""),
|
||||
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb 即可;未返回时回落展示图。
|
||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||
name: im.name || "",
|
||||
}))
|
||||
@ -200,445 +197,34 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
)
|
||||
}
|
||||
|
||||
{/* 大图灯箱:与走秀详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
|
||||
街拍无细节图分组,故不传 details 插槽。 */}
|
||||
{snap && previewImages.length > 0 && (
|
||||
<div
|
||||
x-show="lbOpen"
|
||||
x-cloak
|
||||
@keydown.window.escape="closeLb()"
|
||||
@keydown.window.arrow-right="nextLb()"
|
||||
@keydown.window.arrow-left="prevLb()"
|
||||
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md"
|
||||
x-text="`${lbIndex + 1} / ${imageCount}`"
|
||||
></div>
|
||||
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentFavId, currentFavUrl)"
|
||||
:class="isFav(currentFavId) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-[18px] right-[70px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="closeLb()"
|
||||
class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('Close')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||
<div
|
||||
id="lb-thumbs"
|
||||
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
|
||||
>
|
||||
{previewImages.map((im, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-lb-index={i}
|
||||
data-thumb-url={im.url}
|
||||
@click={`showLb(${i})`}
|
||||
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
|
||||
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
|
||||
: ''"
|
||||
aria-label={`${i + 1}`}
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<template x-for="(im, i) in extraImages" :key="im.url">
|
||||
<button
|
||||
type="button"
|
||||
:data-lb-index="5 + i"
|
||||
:data-thumb-url="im.url"
|
||||
@click="showLb(5 + i)"
|
||||
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
|
||||
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
|
||||
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
|
||||
: ''"
|
||||
:aria-label="String(5 + i + 1)"
|
||||
>
|
||||
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 14l6-6 6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 10l6 6 6-6" /></svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="prevLb()"
|
||||
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('Previous image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="nextLb()"
|
||||
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||||
aria-label={t('Next image')}
|
||||
>
|
||||
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M9 6l6 6 6-6" /></svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="lb-main"
|
||||
@click.self="closeLb()"
|
||||
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||||
x-bind:class="lbZoomed
|
||||
? 'overflow-y-auto overflow-x-hidden items-start'
|
||||
: 'items-center'"
|
||||
>
|
||||
<img
|
||||
id="lb-image"
|
||||
src=""
|
||||
alt=""
|
||||
@click="toggleZoom()"
|
||||
decoding="async"
|
||||
class="block select-none transition-opacity duration-200"
|
||||
x-bind:class="lbZoomed
|
||||
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
|
||||
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6"
|
||||
x-text="currentImageName"
|
||||
></div>
|
||||
|
||||
<div
|
||||
x-show="!lbZoomed"
|
||||
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
|
||||
>
|
||||
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||||
</div>
|
||||
</div>
|
||||
<GalleryLightbox images={previewImages} aspect="3/4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { getStreetSnapDetailAuthed } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
|
||||
import { createGalleryView } from "@/lib/gallery"
|
||||
|
||||
// 图集 + 大图灯箱的共享逻辑全在 lib/gallery.ts;街拍详情没有额外扩展,只补一个 init 调共享初始化。
|
||||
// 回调显式标注返回类型:对象展开会让 Alpine.data 的泛型 T 退化成 {},this.xxx 全部报错(见 Item.astro 说明)。
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("streetSnapView", () => ({
|
||||
lbOpen: false,
|
||||
lbIndex: 0,
|
||||
lbZoomed: false,
|
||||
imageCount: 0,
|
||||
currentImageName: "",
|
||||
Alpine.data(
|
||||
"streetSnapView",
|
||||
(): ReturnType<typeof createGalleryView> & { init(): void } => ({
|
||||
...createGalleryView({
|
||||
rootSelector: "#street-snap-view",
|
||||
gallerySelector: "#snap-gallery",
|
||||
favType: "snap_image",
|
||||
fetchDetail: getStreetSnapDetailAuthed,
|
||||
}),
|
||||
|
||||
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
|
||||
favIds: [],
|
||||
favType: "snap_image",
|
||||
galleryUid: "",
|
||||
galleryTitle: "",
|
||||
galleryBrand: "",
|
||||
|
||||
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
|
||||
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
|
||||
isAuthed: false,
|
||||
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
|
||||
preview: false,
|
||||
// 已登录态图集级(gallery,s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
|
||||
galleryFav: false,
|
||||
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
|
||||
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
|
||||
|
||||
init() {
|
||||
this.favType = this.$el.dataset.favType || "snap_image"
|
||||
this.galleryUid = this.$el.dataset.galleryUid || ""
|
||||
this.galleryTitle = this.$el.dataset.galleryTitle || ""
|
||||
this.galleryBrand = this.$el.dataset.galleryBrand || ""
|
||||
this.preview = this.$el.dataset.preview === "true"
|
||||
this.isAuthed = !!getUser()
|
||||
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#snap-gallery [data-image-index]"
|
||||
).length
|
||||
this.initThumbLazyLoading()
|
||||
this.initImageFavs()
|
||||
this.maybeLocateImageFromUrl()
|
||||
|
||||
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
|
||||
if (getUser()) this.loadAuthedState()
|
||||
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
|
||||
window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
|
||||
},
|
||||
|
||||
// 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
|
||||
// gallery 级(s=)收藏态广播给页面右下角 fav-fab;image 级(j=)收藏态直接重建 favIds。
|
||||
async loadAuthedState() {
|
||||
if (!getUser()) return
|
||||
try {
|
||||
const full = await getStreetSnapDetailAuthed(this.galleryUid)
|
||||
if (!full || !Array.isArray(full.images)) return
|
||||
|
||||
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
|
||||
this.galleryFav = !!full.favorited
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
|
||||
)
|
||||
|
||||
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
|
||||
const favIds: string[] = []
|
||||
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
|
||||
|
||||
if (this.preview) {
|
||||
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
|
||||
id: im.id || "",
|
||||
url: toAbs(im.image || ""),
|
||||
raw: im.image || "",
|
||||
name: im.name || "",
|
||||
}))
|
||||
await this.$nextTick()
|
||||
this.imageCount = document.querySelectorAll(
|
||||
"#snap-gallery [data-image-index]"
|
||||
).length
|
||||
;(full.images || []).slice(5).forEach(mark)
|
||||
this.initThumbLazyLoading()
|
||||
this.maybeLocateImageFromUrl()
|
||||
}
|
||||
;(full.images || []).slice(0, 5).forEach(mark)
|
||||
this.favIds = favIds
|
||||
} catch {
|
||||
/* 失败维持本地/截断态,门禁仍在 */
|
||||
}
|
||||
},
|
||||
|
||||
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
|
||||
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
|
||||
maybeLocateImageFromUrl() {
|
||||
if (typeof window === "undefined") return
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const want = q.get("img")
|
||||
if (!want) return
|
||||
const figures = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
|
||||
)
|
||||
const idx = figures.findIndex((el) => el.dataset.favId === want)
|
||||
if (idx < 0) return
|
||||
const fig = figures[idx] as HTMLElement
|
||||
requestAnimationFrame(() => {
|
||||
if (q.get("lb") === "1") {
|
||||
this.openLb(idx)
|
||||
return
|
||||
}
|
||||
fig.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
fig.classList.add("locate-flash")
|
||||
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
|
||||
})
|
||||
},
|
||||
|
||||
getImageElement(index: number) {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`#snap-gallery [data-image-index="${index}"]`
|
||||
)
|
||||
},
|
||||
getImageUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageUrl || ""
|
||||
},
|
||||
getImageName(index: number) {
|
||||
return this.getImageElement(index)?.dataset.imageName || ""
|
||||
},
|
||||
|
||||
// ===== 单图收藏 =====
|
||||
getImageFavId(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favId || ""
|
||||
},
|
||||
|
||||
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
|
||||
getImageFavUrl(index: number) {
|
||||
return this.getImageElement(index)?.dataset.favUrl || ""
|
||||
},
|
||||
|
||||
allImageFavIds(): string[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>("#snap-gallery [data-fav-id]"),
|
||||
)
|
||||
.map((el) => el.dataset.favId || "")
|
||||
.filter(Boolean)
|
||||
},
|
||||
|
||||
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
|
||||
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
|
||||
initImageFavs() {
|
||||
const ids = this.allImageFavIds()
|
||||
if (!ids.length) return
|
||||
this.favIds = ids.filter((id) => isFavorited(id))
|
||||
},
|
||||
|
||||
isFav(id: string) {
|
||||
return !!id && this.favIds.includes(id)
|
||||
},
|
||||
|
||||
toggleFav(id: string, url: string) {
|
||||
if (!id) return
|
||||
toggleImageFavorite({
|
||||
imageId: id,
|
||||
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
|
||||
imageUrl: url,
|
||||
parentUid: this.galleryUid,
|
||||
title: this.galleryTitle,
|
||||
brand: this.galleryBrand,
|
||||
})
|
||||
this.favIds = this.isFav(id)
|
||||
? this.favIds.filter((x: string) => x !== id)
|
||||
: [...this.favIds, id]
|
||||
},
|
||||
|
||||
get currentFavId() {
|
||||
return this.getImageFavId(this.lbIndex)
|
||||
},
|
||||
|
||||
get currentFavUrl() {
|
||||
return this.getImageFavUrl(this.lbIndex)
|
||||
},
|
||||
|
||||
openLb(index = 0) {
|
||||
if (!this.imageCount) return
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
this.lbOpen = true
|
||||
document.body.style.overflow = "hidden"
|
||||
this.updateLightboxImage()
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
closeLb() {
|
||||
this.lbOpen = false
|
||||
this.lbZoomed = false
|
||||
document.body.style.overflow = ""
|
||||
},
|
||||
showLb(index: number) {
|
||||
if (!this.imageCount) return
|
||||
this.lbIndex = this.normalizeIndex(index)
|
||||
this.lbZoomed = false
|
||||
this.updateLightboxImage()
|
||||
const main = document.getElementById("lb-main")
|
||||
if (main) main.scrollTop = 0
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollThumbIntoView()
|
||||
this.preloadAround()
|
||||
})
|
||||
},
|
||||
nextLb() { this.showLb(this.lbIndex + 1) },
|
||||
prevLb() { this.showLb(this.lbIndex - 1) },
|
||||
|
||||
normalizeIndex(index: number) {
|
||||
if (!this.imageCount) return 0
|
||||
return ((index % this.imageCount) + this.imageCount) % this.imageCount
|
||||
},
|
||||
updateLightboxImage() {
|
||||
const image = document.getElementById("lb-image") as HTMLImageElement | null
|
||||
if (!image) return
|
||||
image.src = this.getImageUrl(this.lbIndex)
|
||||
image.alt = this.getImageName(this.lbIndex)
|
||||
this.currentImageName = this.getImageName(this.lbIndex)
|
||||
},
|
||||
toggleZoom() {
|
||||
this.lbZoomed = !this.lbZoomed
|
||||
const main = document.getElementById("lb-main")
|
||||
if (main) main.scrollTop = 0
|
||||
},
|
||||
scrollThumbIntoView() {
|
||||
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
|
||||
if (!thumbs) return
|
||||
const active = thumbs.querySelector<HTMLElement>(`[data-lb-index="${this.lbIndex}"]`)
|
||||
if (!active) return
|
||||
const target = active.offsetTop - (thumbs.clientHeight - active.clientHeight) / 2
|
||||
thumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
|
||||
},
|
||||
preloadAround() {
|
||||
const indexes = [this.lbIndex, this.normalizeIndex(this.lbIndex - 1), this.normalizeIndex(this.lbIndex + 1)]
|
||||
indexes.forEach((index: number) => {
|
||||
const url = this.getImageUrl(index)
|
||||
if (!url) return
|
||||
const image = new Image()
|
||||
image.decoding = "async"
|
||||
image.src = url
|
||||
})
|
||||
},
|
||||
initThumbLazyLoading() {
|
||||
const thumbs = document.querySelectorAll<HTMLElement>("#lb-thumbs [data-thumb-url]")
|
||||
if (!thumbs.length) return
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
thumbs.forEach((thumb) => { thumb.style.backgroundImage = `url("${thumb.dataset.thumbUrl}")` })
|
||||
return
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return
|
||||
const element = entry.target as HTMLElement
|
||||
const url = element.dataset.thumbUrl
|
||||
if (url) element.style.backgroundImage = `url("${url}")`
|
||||
obs.unobserve(element)
|
||||
})
|
||||
},
|
||||
{ root: document.getElementById("lb-thumbs"), rootMargin: "300px" }
|
||||
)
|
||||
thumbs.forEach((thumb) => observer.observe(thumb))
|
||||
},
|
||||
}))
|
||||
init() {
|
||||
this.galleryInit()
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* 单图收藏爱心:默认描边,已收藏实心 */
|
||||
.fav-btn svg { fill: none; }
|
||||
.fav-btn.is-fav svg { fill: currentColor; }
|
||||
|
||||
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
|
||||
.locate-flash {
|
||||
outline: 3px solid #111;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
|
||||
@media (hover: none) {
|
||||
.fav-btn { opacity: 1 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import "@/styles/look-grid.css"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
@ -216,7 +216,7 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
|
||||
</div>
|
||||
|
||||
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
|
||||
<script is:inline id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
|
||||
|
||||
|
||||
|
||||
@ -238,9 +238,9 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
return {
|
||||
locale: '',
|
||||
SPIN_SVG,
|
||||
yearOptions: opts.yearOptions || [],
|
||||
sortOptions: opts.sortOptions || [],
|
||||
cityOptions: opts.cityOptions || [],
|
||||
yearOptions: (opts.yearOptions || []) as { value: number | string; label: string }[],
|
||||
sortOptions: (opts.sortOptions || []) as { value: string; label: string }[],
|
||||
cityOptions: (opts.cityOptions || []) as { value: string; label: string }[],
|
||||
selectedYear: '' as string,
|
||||
selectedSort: DEFAULT_SORT as string,
|
||||
selectedCity: '' as string,
|
||||
|
||||
315
src/components/pages/AccountPage.astro
Normal file
315
src/components/pages/AccountPage.astro
Normal file
@ -0,0 +1,315 @@
|
||||
---
|
||||
// 账户页的 en/cn 共享实现(语言由 Astro.currentLocale 运行时决定)。
|
||||
// 对应页面:src/pages/{en,cn}/account.astro —— 仅做薄包装,避免同一份 300+ 行实现写两遍。
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
const titleLabel = t("my account")
|
||||
const pleaseLabel = t("please log in")
|
||||
const loginLabel = t("login")
|
||||
const historyLabel = t("my history")
|
||||
const savedLooksLabel = t("saved looks")
|
||||
const loginUrl = href(locale, ROUTES.login) + "?redirect=" + encodeURIComponent(href(locale, ROUTES.account))
|
||||
const archiveUrl = href(locale, ROUTES.runwayLooks)
|
||||
// 预翻译常量:Alpine 表达式里没有 t()(t 仅 Astro 服务端存在),文案一律走模板插值。
|
||||
const removeLabel = t("remove")
|
||||
const itemsLabel = t("items")
|
||||
const clearHistoryLabel = t("clear history")
|
||||
const noSavedLabel = t("no saved looks yet")
|
||||
const noHistoryLabel = t("no history yet")
|
||||
const browseLabel = t("browse the archive")
|
||||
---
|
||||
<Layout title={titleLabel}>
|
||||
<!-- 页面版心与 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 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{pleaseLabel}</p>
|
||||
<p class="mb-12 font-serif 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 font-mono text-[12px] font-bold uppercase tracking-[0.22em] text-white transition-colors hover:bg-white hover:text-black">{loginLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- ===== 已登录 ===== -->
|
||||
<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 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{t("member profile")}</p>
|
||||
<div class="flex flex-wrap items-end gap-x-4">
|
||||
<h1 class="font-serif 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 font-mono text-[10px] font-bold uppercase tracking-[0.3em] text-white">{t("vip")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 pb-1 text-right font-mono 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
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主体:左侧分区索引 + 右侧内容 -->
|
||||
<div class="grid gap-12 pt-12 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16 lg:pt-16">
|
||||
|
||||
<!-- 左:编号目录式垂直导航(移动端折成横向条) -->
|
||||
<nav class="lg:sticky lg:top-12 lg:self-start" aria-label="Account sections">
|
||||
<p class="mb-3 hidden font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400 lg:block">Index</p>
|
||||
<div class="flex gap-10 overflow-x-auto border-b border-black/15 font-mono 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}
|
||||
</button>
|
||||
<button type="button" @click="tab='history'" :class="tab==='history' ? '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">02</span>{historyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0">
|
||||
<!-- ===== Saved looks ===== -->
|
||||
<div x-show="tab==='saved'" x-cloak>
|
||||
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<h2 class="font-serif text-4xl font-black uppercase leading-none tracking-tight md:text-6xl">{savedLooksLabel}</h2>
|
||||
<p class="pb-1 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400"><span class="font-bold text-black" x-text="favoritesCount"></span> {itemsLabel}</p>
|
||||
</div>
|
||||
|
||||
<!-- 粒度切换:图集 / 单图(服务端 kind 过滤分页) -->
|
||||
<div class="mb-10 flex items-center gap-8 border-b border-black/15 font-mono text-[11px] uppercase tracking-[0.2em]">
|
||||
<button type="button" @click="setFavKind('gallery')" :class="favKind==='gallery' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('galleries')}</button>
|
||||
<button type="button" @click="setFavKind('image')" :class="favKind==='image' ? 'border-black text-black' : 'border-transparent text-black/45 hover:text-black'" class="-mb-px border-b-2 py-4 transition-colors">{t('images')}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div x-show="favorites.length === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noSavedLabel}</p>
|
||||
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 收藏网格 -->
|
||||
<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)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] 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 font-mono 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>
|
||||
<div class="mt-3 min-w-0">
|
||||
<a :href="itemHref(f)" class="block">
|
||||
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
|
||||
</a>
|
||||
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 收藏分页(服务端分页,与收藏总量解耦) -->
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="favLastPage > 1" aria-label="Saved pagination">
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage <= 1" @click="goFavPage(favPage - 1)">‹ Prev</button>
|
||||
<template x-for="(p, i) in favPageList" :key="i">
|
||||
<span>
|
||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === favPage }" :disabled="p.page === favPage" @click="goFavPage(p.page)" x-text="p.label"></button>
|
||||
</span>
|
||||
</template>
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="favPage >= favLastPage" @click="goFavPage(favPage + 1)">Next ›</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ===== History ===== -->
|
||||
<div x-show="tab==='history'" x-cloak>
|
||||
|
||||
<div class="mb-10 flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<h2 class="font-serif 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="font-mono 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="font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 underline-offset-4 transition-colors hover:text-black hover:underline">{clearHistoryLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div x-show="histTotal === 0" class="flex flex-col items-center justify-center border-y border-black/10 py-28 text-center">
|
||||
<p class="mb-10 font-serif text-3xl font-black uppercase tracking-tight text-black/80 md:text-4xl">{noHistoryLabel}</p>
|
||||
<a href={archiveUrl} class="inline-flex items-center justify-center border border-black bg-white px-8 py-3.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-black transition-colors hover:bg-black hover:text-white">{browseLabel}</a>
|
||||
</div>
|
||||
|
||||
<!-- 历史网格 -->
|
||||
<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)" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] 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 font-mono 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>
|
||||
<div class="mt-3 min-w-0">
|
||||
<a :href="itemHref(h)" class="block">
|
||||
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
|
||||
</a>
|
||||
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 历史分页 -->
|
||||
<nav class="mt-14 flex flex-wrap items-center justify-center gap-2 font-mono" x-show="histLastPage > 1" aria-label="History pagination">
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage <= 1" @click="goHistPage(histPage - 1)">‹ Prev</button>
|
||||
<template x-for="(p, i) in histPageList" :key="i">
|
||||
<span>
|
||||
<span x-show="p.page === null" x-text="'…'" class="px-2 text-black/40"></span>
|
||||
<button type="button" x-show="p.page !== null" class="border border-black/20 px-4 py-2 text-[11px] tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :class="{ 'bg-black text-white': p.page === histPage }" :disabled="p.page === histPage" @click="goHistPage(p.page)" x-text="p.label"></button>
|
||||
</span>
|
||||
</template>
|
||||
<button type="button" class="border border-black/20 px-4 py-2 text-[11px] uppercase tracking-widest transition-colors hover:bg-black hover:text-white disabled:opacity-30" :disabled="histPage >= histLastPage" @click="goHistPage(histPage + 1)">Next ›</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
/* 触屏没有 hover:卡片右上角操作常驻显示,否则点不到 */
|
||||
@media (hover: none) {
|
||||
.card-act { opacity: 1 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
||||
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
import { buildWindowPages } from "@/lib/looks-grid"
|
||||
|
||||
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"/>' +
|
||||
'<text x="150" y="200" font-family="monospace" font-size="16" letter-spacing="3" fill="#9ca3af" text-anchor="middle">LOOK</text>' +
|
||||
'</svg>'
|
||||
)
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("accountPage", () => ({
|
||||
user: null as any,
|
||||
locale: "en",
|
||||
tab: "saved",
|
||||
favKind: "gallery", // 收藏子分类:gallery=整篇图集 / image=单张图片
|
||||
favorites: [] as any[],
|
||||
favoritesCount: 0,
|
||||
favPage: 1,
|
||||
favPerPage: 24,
|
||||
favLastPage: 1,
|
||||
history: [] as any[],
|
||||
histPage: 1,
|
||||
histPerPage: 24,
|
||||
histLastPage: 1,
|
||||
histTotal: 0,
|
||||
init() {
|
||||
this.locale = this.$el.dataset.locale || "en"
|
||||
const u = getUser()
|
||||
if (!u) {
|
||||
window.location.href = clientHref(this.locale, ROUTES.login) + "?redirect=" + encodeURIComponent(clientHref(this.locale, ROUTES.account))
|
||||
return
|
||||
}
|
||||
this.user = u
|
||||
// 登录态:个人中心收藏以服务端权威列表为准(服务端分页拉取,失败回落本地)。
|
||||
this.loadFavPage(1)
|
||||
// 浏览历史:服务端分页拉取「点开过的文章」,失败回落本地。
|
||||
this.loadHistPage(1)
|
||||
},
|
||||
// 服务端分页拉取收藏(与收藏总量解耦,10万也常数级);kind 决定取图集还是单图
|
||||
async loadFavPage(page: number, kind?: string) {
|
||||
const p = Math.max(1, page | 0)
|
||||
const k = kind || this.favKind
|
||||
try {
|
||||
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
|
||||
this.favorites = r.items
|
||||
this.favLastPage = r.lastPage
|
||||
this.favPage = p
|
||||
this.favoritesCount = r.total
|
||||
} catch {
|
||||
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
|
||||
this.favorites = local
|
||||
this.favLastPage = 1
|
||||
this.favPage = 1
|
||||
this.favoritesCount = local.length
|
||||
}
|
||||
},
|
||||
setFavKind(k: string) {
|
||||
if (this.favKind === k) return
|
||||
this.favKind = k
|
||||
this.favorites = []
|
||||
this.favoritesCount = 0
|
||||
this.favLastPage = 1
|
||||
this.loadFavPage(1, k)
|
||||
},
|
||||
goFavPage(p: number) {
|
||||
if (p < 1 || p > this.favLastPage || p === this.favPage) return
|
||||
this.loadFavPage(p)
|
||||
},
|
||||
// 窗口化页码(实现见 looks-grid.ts 的 buildWindowPages,与历史分页共用)
|
||||
get favPageList() {
|
||||
return buildWindowPages(this.favPage, this.favLastPage)
|
||||
},
|
||||
coverUrl(x: any): string {
|
||||
const c = x.data?.cover || x.cover
|
||||
return c ? toAbs(c) : PLACEHOLDER
|
||||
},
|
||||
itemHref(f: any): string {
|
||||
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
|
||||
// 图集收藏与历史仍用自身 uid,但历史优先用回查到的 canonId(规范 id)——
|
||||
// 旧的无前缀历史 id 会把用户带回非规范地址,详情页据此又写出一条重复历史。
|
||||
const isImg = f.kind === "image"
|
||||
const uid = isImg && f.parentUid ? f.parentUid : (f.canonId || f.id)
|
||||
const base = clientHref(this.locale, ROUTES.item(uid))
|
||||
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
|
||||
},
|
||||
removeFav(id: string) {
|
||||
removeFavorite(id)
|
||||
this.loadFavPage(this.favPage) // 重新拉当前页,保证分页计数一致
|
||||
},
|
||||
// ===== 浏览历史(服务端分页)=====
|
||||
async loadHistPage(page: number) {
|
||||
const p = Math.max(1, page | 0)
|
||||
try {
|
||||
const r = await fetchHistoryPage(p, this.histPerPage)
|
||||
this.history = r.items
|
||||
this.histTotal = r.total
|
||||
this.histLastPage = r.lastPage
|
||||
this.histPage = p
|
||||
} catch {
|
||||
this.history = []
|
||||
this.histTotal = 0
|
||||
this.histLastPage = 1
|
||||
this.histPage = 1
|
||||
}
|
||||
},
|
||||
goHistPage(p: number) {
|
||||
if (p < 1 || p > this.histLastPage || p === this.histPage) return
|
||||
this.loadHistPage(p)
|
||||
},
|
||||
// 窗口化页码(实现见 looks-grid.ts 的 buildWindowPages,与收藏分页共用)
|
||||
get histPageList() {
|
||||
return buildWindowPages(this.histPage, this.histLastPage)
|
||||
},
|
||||
removeHist(id: string) {
|
||||
removeHistory(id)
|
||||
this.loadHistPage(this.histPage) // 重新拉当前页,保证分页计数一致
|
||||
},
|
||||
clearAllHistory() {
|
||||
clearHistory()
|
||||
this.loadHistPage(1)
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
135
src/components/pages/ItemPage.astro
Normal file
135
src/components/pages/ItemPage.astro
Normal file
@ -0,0 +1,135 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import Item from "@/components/Item.astro"
|
||||
import StreetSnap from "@/components/StreetSnap.astro"
|
||||
import { getSsrArticle, getSsrStreetSnap } from "@/lib/ssr"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
|
||||
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
|
||||
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
|
||||
//
|
||||
// 本组件是 en/cn 两页的共享实现(语言由 Astro.currentLocale 运行时决定)。
|
||||
// 注意:`export const prerender = false` 必须留在**页面**文件里(Astro 只读页面模块的该导出),
|
||||
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
|
||||
interface Props {
|
||||
id?: string
|
||||
}
|
||||
|
||||
const { id } = Astro.props
|
||||
const isSnap = !!id && id[0] === "s"
|
||||
|
||||
const { t } = getI18n(Astro)
|
||||
|
||||
let article = null
|
||||
let articleErr = null
|
||||
let snap = null
|
||||
let snapErr = null
|
||||
let preview = false
|
||||
let imageTotal = 0
|
||||
|
||||
// await 必须位于 frontmatter 顶层(Astro 不支持块内顶层 await)
|
||||
const data = isSnap
|
||||
? await getSsrStreetSnap(id, Astro.currentLocale)
|
||||
: await getSsrArticle(id, Astro.currentLocale)
|
||||
|
||||
// 用 in 收窄联合类型:isSnap 是运行时判断,TS 无法据此关联 data 的具体分支。
|
||||
if (isSnap) {
|
||||
snap = "snap" in data ? data.snap : null
|
||||
snapErr = data.error
|
||||
} else {
|
||||
article = "article" in data ? data.article : null
|
||||
articleErr = data.error
|
||||
}
|
||||
preview = data.preview ?? false
|
||||
imageTotal = data.imageTotal ?? 0
|
||||
|
||||
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
|
||||
// id 优先取**接口回传的规范 id**(带类型前缀):用户若通过旧的无前缀链接进来(如 /item/0008CZYI),
|
||||
// 也保证收藏 / 历史写入的是规范形式,不会与既有记录形成「同一篇文章两行」。
|
||||
const canonId = String((isSnap ? snap?.id : article?.id) ?? id ?? "")
|
||||
const meta = isSnap
|
||||
? { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
|
||||
: { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
|
||||
const saveLabel = t("save")
|
||||
const savedLabel = t("saved")
|
||||
---
|
||||
|
||||
<Layout>
|
||||
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
#fav-fab.is-active .fav-heart { fill: currentColor; }
|
||||
</style>
|
||||
|
||||
{(article || snap) && (
|
||||
<button login-required id="fav-fab" type="button"
|
||||
data-id={meta.id}
|
||||
data-type={meta.type}
|
||||
data-title={meta.title}
|
||||
data-cover={meta.cover}
|
||||
data-brand={meta.brand}
|
||||
data-save-label={saveLabel}
|
||||
data-saved-label={savedLabel}
|
||||
class="fixed bottom-6 right-6 z-50 inline-flex items-center gap-2 rounded-full border border-black/40 bg-white/90 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-black shadow-sm backdrop-blur transition-colors hover:bg-black hover:text-white">
|
||||
<svg class="fav-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
|
||||
<span id="fav-label">{saveLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 注意:不能用 define:vars 传 meta —— 它会把脚本包成内联 IIFE 并把 import 塞进函数体内,
|
||||
导致 SyntaxError(import 只能在模块顶层)。故改由 data-* 属性透传,脚本自行读取。 */}
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleFavorite, hydrateFavorited } from "@/lib/favorites"
|
||||
import { recordHistory } from "@/lib/history"
|
||||
|
||||
const btn = document.getElementById("fav-fab")
|
||||
const label = document.getElementById("fav-label")
|
||||
if (btn) {
|
||||
const meta = {
|
||||
id: btn.dataset.id || "",
|
||||
type: btn.dataset.type || "",
|
||||
title: btn.dataset.title || "",
|
||||
cover: btn.dataset.cover || "",
|
||||
brand: btn.dataset.brand || "",
|
||||
}
|
||||
const saveLabel = btn.dataset.saveLabel || "save"
|
||||
const savedLabel = btn.dataset.savedLabel || "saved"
|
||||
|
||||
if (meta.id) {
|
||||
// 浏览历史:点开文章详情即记一条(登录态;列表页不再记)。同一篇反复打开,后端 upsert 刷新时间。
|
||||
if (getUser()) recordHistory(meta.id)
|
||||
|
||||
const sync = () => {
|
||||
const fav = isFavorited(meta.id)
|
||||
if (label) label.textContent = fav ? savedLabel : saveLabel
|
||||
btn.classList.toggle("is-active", fav)
|
||||
}
|
||||
sync()
|
||||
|
||||
// 登录态收藏校正已合并进详情接口:articleView / streetSnapView 取到服务端收藏态后,
|
||||
// 通过 fav:set 事件广播当前图集是否已收藏;此处监听并写回本地(换设备后与服务端一致)。
|
||||
// 不再单独请求 /me/favorites/checks。
|
||||
window.addEventListener("fav:set", (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ id: string; fav: boolean }>).detail
|
||||
if (!detail || detail.id !== meta.id) return
|
||||
if (detail.fav) {
|
||||
hydrateFavorited(meta.id, meta.type as "runway" | "street", meta.title, meta.cover, meta.brand)
|
||||
}
|
||||
sync()
|
||||
})
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
toggleFavorite({
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
title: meta.title,
|
||||
cover: meta.cover,
|
||||
brand: meta.brand,
|
||||
} as any)
|
||||
sync()
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
170
src/components/pages/LoginPage.astro
Normal file
170
src/components/pages/LoginPage.astro
Normal file
@ -0,0 +1,170 @@
|
||||
---
|
||||
// 登录页的 en/cn 共享实现(语言由 Astro.currentLocale 运行时决定)。
|
||||
// 对应页面:src/pages/{en,cn}/login.astro —— 仅做薄包装。
|
||||
// 注意:本页**不走 Layout**,自带完整 <html> 文档(独立极简版式,故保留 <!doctype html>)。
|
||||
import '@/styles/global.css'
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { LOCALE_NAMES } from '@/lib/i18n'
|
||||
import { i18n } from 'astro:config/client';
|
||||
import { toCodes } from 'astro:i18n';
|
||||
|
||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
|
||||
const pathName = Astro.url.pathname
|
||||
const locales = toCodes(i18n!.locales)
|
||||
const pathWithoutLocale = locales.includes(pathName.split('/')[1])
|
||||
? pathName.replace(`/${locale}`, '')
|
||||
: pathName
|
||||
const languages = locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName }))
|
||||
|
||||
const loginLabel = t('login')
|
||||
const promptLabel = t('login prompt')
|
||||
const accountLabel = t('username or email')
|
||||
const passwordLabel = t('password')
|
||||
const failLabel = t('login failed')
|
||||
const membersLabel = t('members')
|
||||
const backLabel = t('back to site')
|
||||
const homeUrl = href(locale, ROUTES.runwayLooks)
|
||||
const pageTitle = `${loginLabel} — Portfolio Studio`
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{pageTitle}</title>
|
||||
</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">
|
||||
|
||||
<!-- 顶部工具行:返回档案 + 语言切换 -->
|
||||
<header class="mb-12 flex items-center justify-between gap-6">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black/50 transition-colors hover:text-black">
|
||||
<span aria-hidden="true">←</span>{backLabel}
|
||||
</a>
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false" @click.outside="open = false">
|
||||
<button type="button" @click="open = !open" class="inline-flex items-center gap-2 border border-black/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black transition-colors hover:bg-black hover:text-white" aria-label={t('Language')} aria-haspopup="listbox" :aria-expanded="open">
|
||||
<span>{LOCALE_NAMES[locale].nativeName}</span>
|
||||
<svg viewBox="0 0 12 12" width="11" height="11" class="transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
|
||||
<path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul x-show="open" x-cloak class="absolute right-0 top-full z-20 mt-2 min-w-[170px] border border-black/15 bg-white py-1">
|
||||
{languages.map((l) => (
|
||||
<li>
|
||||
<a href={getRelativeLocaleUrl(l.code, pathWithoutLocale)} @click="open = false" class:list={['block px-4 py-2 font-mono text-[11px] uppercase tracking-[0.2em] transition-colors hover:bg-black hover:text-white', l.code === locale ? 'font-bold text-black' : 'text-black/55']}>{l.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 报头:与账户页同一套 masthead(mono 眉标 + 衬线巨标题 + 档案标签 + 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 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">{membersLabel}</p>
|
||||
<h1 class="font-serif text-6xl font-black uppercase leading-[0.88] tracking-tight md:text-8xl">{loginLabel}</h1>
|
||||
</div>
|
||||
<div class="pb-1 text-right font-mono 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
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主体:左表单 / 右档案说明(mobile 单栏堆叠) -->
|
||||
<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 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-400">{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 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{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 font-mono text-[11px] uppercase tracking-[0.22em] text-zinc-500">{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>
|
||||
|
||||
<p x-show="error" x-text="error" x-cloak class="font-mono text-[11px] uppercase tracking-[0.2em] text-red-600"></p>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full border border-black bg-black py-4 font-mono text-[12px] font-bold uppercase tracking-[0.2em] text-white transition-colors hover:bg-white hover:text-black disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<span x-show="!loading">{loginLabel}</span>
|
||||
<span x-show="loading" x-cloak>…</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-10 border-t border-black/15 pt-5">
|
||||
<a href={homeUrl} class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-400 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 font-mono text-[11px] uppercase tracking-[0.3em] text-zinc-400">Archive note</p>
|
||||
<p class="font-serif 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 font-mono text-[10px] uppercase tracking-[0.3em] text-zinc-400">
|
||||
<p class="mb-3">Runway · Street · Archive</p>
|
||||
<p class="tracking-[0.3em]">Paris · Milan · Tokyo</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("loginForm", () => ({
|
||||
account: "",
|
||||
password: "",
|
||||
error: "",
|
||||
loading: false,
|
||||
|
||||
init() {
|
||||
if (getUser()) {
|
||||
const loc = this.$el.dataset.locale || "en"
|
||||
window.location.href = clientHref(loc, ROUTES.account)
|
||||
}
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.error = ""
|
||||
this.loading = true
|
||||
|
||||
login({ account: this.account, password: this.password })
|
||||
.then(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const redirect = params.get("redirect")
|
||||
const loc = this.$el.dataset.locale || "en"
|
||||
window.location.href = redirect || clientHref(loc, ROUTES.account)
|
||||
})
|
||||
.catch(() => {
|
||||
this.error = this.$el.dataset.failLabel || "login failed"
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user