@ -1,37 +1,34 @@
|
||||
|
||||
---
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
import { getShows, seriesLabel } from "@/lib/data"
|
||||
import { toAbs } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { getRelativeLocaleUrl } from 'astro:i18n'
|
||||
|
||||
|
||||
const shows = await getShows(20)
|
||||
import { getSsgPopularBrands } from "@/lib/ssg"
|
||||
|
||||
// 国际化:一行取当前语言 + 翻译函数
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
type GalleryItem = {
|
||||
src: string;
|
||||
brand: string;
|
||||
articleId: number;
|
||||
year: number;
|
||||
title: string;
|
||||
collection_type?: string
|
||||
// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌,
|
||||
// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
||||
// 具体 base 地址 + token 拼接已在 @/lib/ssg 内封装。
|
||||
const popular = await getSsgPopularBrands(20)
|
||||
|
||||
type GalleryItem = {
|
||||
src: string
|
||||
brand: string
|
||||
articleId: string // 代表走秀 hashid 编码串(后端返回 string)
|
||||
title: string
|
||||
}
|
||||
|
||||
const galleryItems: GalleryItem[] = []
|
||||
for (const s of shows) {
|
||||
if (!s.img) continue
|
||||
galleryItems.push({
|
||||
src: s.img,
|
||||
brand: s.brand,
|
||||
articleId: s.id,
|
||||
year: s.year,
|
||||
title: s.title,
|
||||
collection_type: s.collection_type,
|
||||
})
|
||||
}
|
||||
const galleryItems: GalleryItem[] = popular
|
||||
.filter((b) => b.cover && b.article_id)
|
||||
.map((b) => ({
|
||||
src: toAbs(b.cover || ""),
|
||||
brand: b.brand,
|
||||
articleId: b.article_id,
|
||||
title: b.title,
|
||||
}))
|
||||
---
|
||||
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
|
||||
<!-- 二级分类标签页 -->
|
||||
@ -89,7 +86,7 @@ for (const s of shows) {
|
||||
{
|
||||
galleryItems.map((g, i) => (
|
||||
<div class="swiper-slide hs-slide">
|
||||
<a class="hs-item" href={getRelativeLocaleUrl(locale, `/runway-looks/${g.articleId}`)} data-idx={i}>
|
||||
<a class="hs-item" href={getRelativeLocaleUrl(locale, `/items/${g.articleId}`)} data-idx={i}>
|
||||
<!-- Alpine 管理单张图片的加载/失败状态 -->
|
||||
<div
|
||||
class="hs-img-box"
|
||||
@ -111,12 +108,9 @@ for (const s of shows) {
|
||||
@load="loaded = true; $dispatch('img-ready')"
|
||||
@error="error = true; $dispatch('img-ready')"
|
||||
/>
|
||||
<span class="hs-caption">
|
||||
<span class="hs-caption font-sans">
|
||||
<span class="hs-cap-brand">{g.brand}</span>
|
||||
<span class="hs-cap-meta">
|
||||
{g.year ? g.year : ""}
|
||||
{g.collection_type ? " · " + seriesLabel(g.collection_type) : ""}
|
||||
</span>
|
||||
<span class="hs-cap-meta">{g.title}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@ -269,36 +263,51 @@ for (const s of shows) {
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* 悬停品牌遮罩 */
|
||||
/* 底部品牌/标题信息——左下角大标题+小副标题,始终可见 */
|
||||
.hs-caption {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 28px 16px 12px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
|
||||
padding: 48px 24px 22px;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.28) 55%, transparent 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
opacity: 1;
|
||||
z-index: 3;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hs-item:hover .hs-caption { opacity: 1; }
|
||||
.hs-cap-brand {
|
||||
font-size: 13px;
|
||||
@apply font-sans;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.1;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.hs-cap-meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
letter-spacing: 0.12em;
|
||||
@apply font-sans;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
max-width: 90%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hs-caption { padding: 60px 28px 26px; }
|
||||
.hs-cap-brand { font-size: 26px; }
|
||||
.hs-cap-meta { font-size: 15px; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
@ -71,26 +70,24 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||
<input class="showssearch" placeholder="Search designer…" autocomplete="off" x-model.debounce.150ms="keyword" />
|
||||
<ul class="showschecks">
|
||||
<li>
|
||||
<!-- Letter Header -->
|
||||
<template x-if="item.type === 'letter'">
|
||||
<div class="letter-head" x-text="item.letter"></div>
|
||||
</template>
|
||||
<!-- <li>
|
||||
<template x-if="item.type === 'letter'">
|
||||
<div class="letter-head" x-text="item.letter"></div>
|
||||
</template>
|
||||
|
||||
<!-- Brand Item -->
|
||||
<template x-if="item.type === 'brand'">
|
||||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
:checked="selectedBrands.includes(item.id)"
|
||||
@change="toggleBrand(item.id)"
|
||||
>
|
||||
<span x-text="item.name"></span>
|
||||
<span class="ct" x-text="item.count ? item.count : ''"></span>
|
||||
</label>
|
||||
</template>
|
||||
</li>
|
||||
<template x-if="item.type === 'brand'">
|
||||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
:checked="selectedBrands.includes(item.id)"
|
||||
@change="toggleBrand(item.id)"
|
||||
>
|
||||
<span x-text="item.name"></span>
|
||||
<span class="ct" x-text="item.count ? item.count : ''"></span>
|
||||
</label>
|
||||
</template>
|
||||
</li> -->
|
||||
</ul>
|
||||
<a class="showsmore" @click="openBrandModal()" x-text="'浏览全部品牌 +'"></a>
|
||||
</div>
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
---
|
||||
import type { RawArticle } from "@/lib/data"
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
|
||||
interface Props {
|
||||
article: RawArticle | null
|
||||
locale?: "en" | "cn"
|
||||
apiBase?: string
|
||||
error?: "missing" | "failed" | null
|
||||
}
|
||||
|
||||
const {
|
||||
article,
|
||||
locale = "en",
|
||||
apiBase = "",
|
||||
error = null,
|
||||
} = Astro.props
|
||||
const { article, apiBase = "", error = null } = Astro.props
|
||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
|
||||
const full = (u: string): string => {
|
||||
if (!u) return ""
|
||||
@ -35,36 +31,12 @@ const descParas = (article?.description || "")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const L = {
|
||||
en: {
|
||||
back: "← Back to Index",
|
||||
missing: "No article ID specified",
|
||||
failed: "Failed to load article. Please refresh or try later.",
|
||||
close: "Close",
|
||||
previous: "Previous image",
|
||||
next: "Next image",
|
||||
zoomTip: "Click image to zoom · Scroll to view · Esc to close",
|
||||
},
|
||||
cn: {
|
||||
back: "← 返回首页",
|
||||
missing: "未指定文章 ID",
|
||||
failed: "加载文章失败,请刷新或稍后再试",
|
||||
close: "关闭",
|
||||
previous: "上一张",
|
||||
next: "下一张",
|
||||
zoomTip: "点击图片放大 · 上下滚动查看 · Esc 关闭",
|
||||
},
|
||||
}
|
||||
|
||||
const lbl = L[locale === "cn" ? "cn" : "en"]
|
||||
const backLabel = lbl.back.replace("← ", "")
|
||||
|
||||
const homeHref = locale === "cn" ? "/cn" : "/en/"
|
||||
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
|
||||
const homeHref = getRelativeLocaleUrl(locale, "/")
|
||||
const archiveHref = getRelativeLocaleUrl(locale, "/shows")
|
||||
|
||||
const crumbs: { label: string; href: string }[] = [
|
||||
{
|
||||
label: "Runway Looks",
|
||||
label: t("Runway looks"),
|
||||
href: archiveHref,
|
||||
},
|
||||
]
|
||||
@ -139,14 +111,14 @@ if (article?.brand_id) {
|
||||
) : (
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
|
||||
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
|
||||
{error === "missing" ? lbl.missing : lbl.failed}
|
||||
{error === "missing" ? t('No article ID specified') : t('Failed to load article. Please refresh or try later.')}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={homeHref}
|
||||
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
||||
>
|
||||
← {backLabel}
|
||||
← {t('Back to Index')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
@ -171,7 +143,7 @@ if (article?.brand_id) {
|
||||
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={lbl.close}
|
||||
aria-label={t('Close')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@ -204,7 +176,7 @@ if (article?.brand_id) {
|
||||
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={lbl.previous}
|
||||
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" />
|
||||
@ -215,7 +187,7 @@ if (article?.brand_id) {
|
||||
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={lbl.next}
|
||||
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" />
|
||||
@ -226,7 +198,7 @@ if (article?.brand_id) {
|
||||
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={lbl.previous}
|
||||
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" />
|
||||
@ -237,7 +209,7 @@ if (article?.brand_id) {
|
||||
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={lbl.next}
|
||||
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" />
|
||||
@ -275,7 +247,7 @@ if (article?.brand_id) {
|
||||
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"
|
||||
>
|
||||
{lbl.zoomTip}
|
||||
{t('Click image to zoom · Scroll to view · Esc to close')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -459,12 +431,12 @@ if (article?.brand_id) {
|
||||
})
|
||||
},
|
||||
|
||||
// TODO 这是干啥的
|
||||
initThumbLazyLoading() {
|
||||
const thumbs = document.querySelectorAll<HTMLElement>(
|
||||
"#lb-thumbs [data-thumb-url]"
|
||||
)
|
||||
|
||||
console.log(thumbs)
|
||||
if (!thumbs.length) return
|
||||
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
---
|
||||
import { getReports, fmtDate } from "@/lib/data"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
|
||||
// 文章展示创意布局页:复用你现有的 Layout 外壳。
|
||||
// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
|
||||
@ -7,9 +8,11 @@ import { getReports, fmtDate } from "@/lib/data"
|
||||
|
||||
const reports = await getReports(30)
|
||||
const fmt = fmtDate
|
||||
const { locale, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
const itemBase = getRelativeLocaleUrl(locale, "/items/").replace(/\/$/, "")
|
||||
---
|
||||
|
||||
<div class="archive">
|
||||
<div class="archive" data-item-base={itemBase}>
|
||||
<!-- 页头(极简黑白编辑风,与首页 Hero 一致的排版语言) -->
|
||||
<header class="archive-head">
|
||||
<div class="archive-kicker">
|
||||
@ -65,7 +68,7 @@ const fmt = fmtDate
|
||||
style={`--ratio:${s.ratio}`}
|
||||
>
|
||||
<div class="card-datecol"><span class="card-date">{fmt(s.created_at)}</span></div>
|
||||
<a class="card-media" href={`/article?id=${s.id}`} aria-label={s.title}>
|
||||
<a class="card-media" href={`${itemBase}/${s.id}`} aria-label={s.title}>
|
||||
<img src={s.cover} alt={s.title} loading="lazy" />
|
||||
{s.featured && <span class="card-flag">Featured</span>}
|
||||
</a>
|
||||
@ -269,6 +272,7 @@ const fmt = fmtDate
|
||||
const filterbar = document.getElementById("filterbar")
|
||||
const viewBtns = Array.from(document.querySelectorAll<HTMLButtonElement>(".viewswitch button"))
|
||||
const API_BASE = (import.meta.env.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
|
||||
const itemBase = archive?.dataset.itemBase || "/items"
|
||||
// 封面可能是完整外链(https://...),也可能是相对路径(/uploads/...),统一处理
|
||||
const toAbs = (url: string) => {
|
||||
if (!url) return ""
|
||||
@ -310,7 +314,7 @@ const fmt = fmtDate
|
||||
<article class="card" data-brand="${it.brand}" data-featured="${it.featured ? "true" : "false"}"
|
||||
data-year="${it.year}" data-archive="${archiveFlag}" style="--ratio:${ratio}">
|
||||
<div class="card-datecol"><span class="card-date">${fmt(it.created_at)}</span></div>
|
||||
<a class="card-media" href="/article?id=${it.id}" aria-label="${it.title}">
|
||||
<a class="card-media" href="${itemBase}/${it.id}" aria-label="${it.title}">
|
||||
<img src="${it.cover}" alt="${it.title}" loading="lazy" />
|
||||
${it.featured ? '<span class="card-flag">Featured</span>' : ""}
|
||||
</a>
|
||||
|
||||
@ -1,37 +1,19 @@
|
||||
---
|
||||
import { getSsgBrands, type SsgBrand } from "@/lib/ssg"
|
||||
|
||||
type Brand = SsgBrand
|
||||
|
||||
// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
|
||||
// - 数据:一次 SSR 拉完全部精选集合(?size=300&only_with_articles=1&featured=1),
|
||||
// 前端按 name 首字母分桶渲染;198 个品牌数据量小,无需懒加载。
|
||||
// - 数据:一次 SSR 拉完全部精选集合,前端按 name 首字母分桶渲染;数据量小,无需懒加载。
|
||||
// - 风格:极简编辑风(黑白 + monospace + 细线)。
|
||||
// · 顶部单行 BRANDS 标题 + 总数
|
||||
// · sticky A-Z 字母条(点击平滑滚动)
|
||||
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
|
||||
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
|
||||
// 构建期 SSR 拉数据用 API_SSR(容器内部后端地址,如 http://backend:8090);
|
||||
// 运行期浏览器走同源 /api(由 nginx 反代),PUBLIC_API_BASE 置空即可。
|
||||
const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
|
||||
|
||||
interface Brand {
|
||||
id: number
|
||||
name: string
|
||||
show_name: string
|
||||
article_count: number
|
||||
}
|
||||
|
||||
let brands: Brand[] = []
|
||||
let loadError = false
|
||||
try {
|
||||
const res = await fetch(`${API}/api/public/brands?size=300&only_with_articles=1&featured=1`)
|
||||
if (res.ok) {
|
||||
const j = await res.json()
|
||||
brands = (j?.data ?? []) as Brand[]
|
||||
} else {
|
||||
loadError = true
|
||||
}
|
||||
} catch {
|
||||
loadError = true
|
||||
}
|
||||
// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091);
|
||||
// base 地址 + token 拼接已在 @/lib/ssg 内封装。后端不可达则回落空数组(页面显示空索引)。
|
||||
const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 })
|
||||
const loadError = brands.length === 0
|
||||
|
||||
const LETTERS = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user