This commit is contained in:
toom1996
2026-09-15 00:31:31 +08:00
parent afff2d9a49
commit 199300b203
27 changed files with 114 additions and 132 deletions

View File

@ -1,54 +0,0 @@
---
import { getI18n } from "@/lib/i18n";
interface Crumb {
/** i18n key:英文原文,如 'Home' / 'Runway Archive';中文环境自动取字典翻译 */
label: string;
href: string;
}
interface Props {
/** 中间层级的面包屑项(不含末项)。末项由 <slot name="current"> 提供(如动态年份) */
items?: Crumb[];
/** 可选:显式指定语言;缺省时跟随当前页面语言(Astro.currentLocale) */
locale?: string;
}
const {t, locale, getRelativeLocaleUrl} = getI18n(Astro);
const { items = [] } = Astro.props;
---
<nav class="crumb" aria-label="Breadcrumb">
<Fragment>
<a href={getRelativeLocaleUrl(locale, '/')} class="crumb-link">{t('Home')}</a>
<span class="crumb-sep" aria-hidden="true">/</span>
</Fragment>
{
items.map((it) => (
<Fragment>
<a href={it.href} class="crumb-link">{t(it.label)}</a>
<span class="crumb-sep" aria-hidden="true">/</span>
</Fragment>
))
}
<span class="crumb-cur" aria-current="page">
<slot name="current" />
</span>
</nav>
<style>
@reference "tailwindcss";
.crumb {
@apply inline-flex items-center gap-[10px] mb-[10px] text-[11px] tracking-[0.18em] uppercase text-[#999] no-underline;
}
.crumb-link {
@apply text-black no-underline transition-colors duration-150 hover:text-[#888];
}
.crumb-sep {
@apply text-[#cfcfcf] select-none;
}
.crumb-cur {
@apply text-[#999];
}
</style>

View File

@ -162,18 +162,6 @@ const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
</div> </div>
</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 的细节图簇」;街拍页不传) */} {/* 各页独有的附加层(走秀页注入「当前 look 的细节图簇」;街拍页不传) */}
<slot name="details" /> <slot name="details" />
</div> </div>

View File

@ -1,6 +1,5 @@
--- ---
import type { RawStreetSnap } from "@/lib/api" import type { RawStreetSnap } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro"
import GalleryLightbox from "@/components/GalleryLightbox.astro" import GalleryLightbox from "@/components/GalleryLightbox.astro"
import { getI18n } from "@/lib/i18n" import { getI18n } from "@/lib/i18n"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
@ -55,10 +54,6 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
{ {
snap ? ( snap ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16"> <article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<Breadcrumb>
<span slot="current" class="max-w-[60vw] truncate">{displayTitle}</span>
</Breadcrumb>
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8"> <h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{displayTitle} {displayTitle}
</h1> </h1>

View File

@ -4,9 +4,18 @@
import Layout from "@/layouts/Layout.astro" import Layout from "@/layouts/Layout.astro"
import { getI18n } from "@/lib/i18n" import { getI18n } from "@/lib/i18n"
import { href, ROUTES } from "@/lib/routes" import { href, ROUTES } from "@/lib/routes"
import { config } from '@/lib/config'
const { locale, t } = getI18n(Astro) const { locale, t } = getI18n(Astro)
const SITE_NAME = config.siteName ?? "Xisoa"
const accountDesc = locale === 'cn'
? `管理你在 ${SITE_NAME} 的收藏造型与浏览历史。`
: `Manage your saved looks and view history on your ${SITE_NAME} account.`
const accountKeywords = locale === 'cn'
? `账户,收藏,浏览历史,${SITE_NAME}`
: `account,saved looks,history,${SITE_NAME}`
const titleLabel = t("my account") const titleLabel = t("my account")
const pleaseLabel = t("please log in") const pleaseLabel = t("please log in")
const loginLabel = t("login") const loginLabel = t("login")
@ -22,7 +31,7 @@ const noSavedLabel = t("no saved looks yet")
const noHistoryLabel = t("no history yet") const noHistoryLabel = t("no history yet")
const browseLabel = t("browse the archive") const browseLabel = t("browse the archive")
--- ---
<Layout title={titleLabel}> <Layout title={titleLabel} description={accountDesc} keywords={accountKeywords}>
<!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 --> <!-- 页面版心与 Layout 一致:黑白编辑风,衬线大标题 + 等宽小标 + 粗黑细线 -->
<main class="account-root mx-auto w-full max-w-[1400px] px-5 py-14 md:px-10 md:py-20 font-sans" x-data="accountPage" data-locale={locale}> <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}>
@ -96,12 +105,12 @@ const browseLabel = t("browse the archive")
<div x-show="favorites.length > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5"> <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"> <template x-for="f in favorites" :key="f.id">
<div class="group"> <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"> <a :href="itemHref(f)" target="_blank" rel="noopener" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
<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]" /> <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> </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> <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"> <div class="mt-3 min-w-0">
<a :href="itemHref(f)" class="block"> <a :href="itemHref(f)" target="_blank" rel="noopener" class="block">
<p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p> <p class="truncate text-[13px] font-medium leading-snug" x-text="f.title"></p>
</a> </a>
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p> <p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="f.brand || '—'"></p>
@ -144,12 +153,12 @@ const browseLabel = t("browse the archive")
<div x-show="histTotal > 0" class="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-3 xl:grid-cols-4 lg:gap-x-5"> <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"> <template x-for="h in history" :key="h.id">
<div class="group"> <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"> <a :href="itemHref(h)" target="_blank" rel="noopener" class="relative block aspect-[3/4] overflow-hidden border border-black/10 bg-[#f1f1f1] transition-colors group-hover:border-black">
<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]" /> <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> </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> <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"> <div class="mt-3 min-w-0">
<a :href="itemHref(h)" class="block"> <a :href="itemHref(h)" target="_blank" rel="noopener" class="block">
<p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p> <p class="truncate text-[13px] font-medium leading-snug" x-text="h.data?.title || '…'"></p>
</a> </a>
<p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p> <p class="mt-1.5 truncate font-mono text-[10px] uppercase tracking-[0.16em] text-zinc-400" x-text="h.data?.brand || '—'"></p>
@ -189,7 +198,7 @@ const browseLabel = t("browse the archive")
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites" import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history" import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
import { buildWindowPages } from "@/lib/looks-grid" import { buildWindowPages } from "@/lib/ui"
const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent( const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' + '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400">' +
@ -257,7 +266,7 @@ const browseLabel = t("browse the archive")
if (p < 1 || p > this.favLastPage || p === this.favPage) return if (p < 1 || p > this.favLastPage || p === this.favPage) return
this.loadFavPage(p) this.loadFavPage(p)
}, },
// 窗口化页码(实现见 looks-grid.ts 的 buildWindowPages,与历史分页共用) // 窗口化页码(实现见 ui.ts 的 buildWindowPages,与历史分页共用)
get favPageList() { get favPageList() {
return buildWindowPages(this.favPage, this.favLastPage) return buildWindowPages(this.favPage, this.favLastPage)
}, },
@ -298,7 +307,7 @@ const browseLabel = t("browse the archive")
if (p < 1 || p > this.histLastPage || p === this.histPage) return if (p < 1 || p > this.histLastPage || p === this.histPage) return
this.loadHistPage(p) this.loadHistPage(p)
}, },
// 窗口化页码(实现见 looks-grid.ts 的 buildWindowPages,与收藏分页共用) // 窗口化页码(实现见 ui.ts 的 buildWindowPages,与收藏分页共用)
get histPageList() { get histPageList() {
return buildWindowPages(this.histPage, this.histLastPage) return buildWindowPages(this.histPage, this.histLastPage)
}, },

View File

@ -1,9 +1,10 @@
--- ---
import Layout from "@/layouts/Layout.astro" import Layout from "@/layouts/Layout.astro"
import Item from "@/components/Item.astro" import RunwayLookCard from "@/components/RunwayLookCard.astro"
import StreetSnap from "@/components/StreetSnap.astro" import StreetSnapCard from "@/components/StreetSnapCard.astro"
import { getSsrArticle, getSsrStreetSnap } from "@/lib/ssr" import { getSsrArticle, getSsrStreetSnap } from "@/lib/ssr"
import { getI18n } from "@/lib/i18n" import { getI18n } from "@/lib/i18n"
import { config } from '@/lib/config'
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。 // 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。 // 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
@ -18,7 +19,7 @@ interface Props {
const { id } = Astro.props const { id } = Astro.props
const isSnap = !!id && id[0] === "s" const isSnap = !!id && id[0] === "s"
const { t } = getI18n(Astro) const { locale, t } = getI18n(Astro)
let article = null let article = null
let articleErr = null let articleErr = null
@ -50,12 +51,29 @@ const canonId = String((isSnap ? snap?.id : article?.id) ?? id ?? "")
const meta = isSnap const meta = isSnap
? { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" } ? { 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 ?? "" } : { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
// 详情页动态 TDK:标题用「品牌 + 系列」,描述/关键词按语言与类型生成(避免嵌套反引号,用拼接)。
const SITE_NAME = config.siteName ?? "Xisoa"
const runwayName = article ? (article.brand_name + " " + article.title).trim() : ""
const baseName = isSnap ? (snap?.title || "") : runwayName
const detailTitle = baseName || SITE_NAME
const detailDesc = isSnap
? (locale === 'cn'
? `${baseName || SITE_NAME} —— 来自全球街头的时尚抓拍,收录于 ${SITE_NAME} 时尚档案。`
: `${baseName || SITE_NAME} — street style snaps from cities around the world, in the ${SITE_NAME} fashion archive.`)
: (locale === 'cn'
? `${baseName || SITE_NAME} 走秀造型共 ${imageTotal} 张,查看细节图与系列信息,收录于 ${SITE_NAME} 时尚档案。`
: `${baseName || SITE_NAME} — ${imageTotal} runway looks with detail images and collection info, in the ${SITE_NAME} fashion archive.`)
const detailKeywords = isSnap
? `${snap?.title || ''},street style,street snaps,fashion,${SITE_NAME}`.replace(/^,/, '')
: `${article?.brand_name || ''},runway,runway looks,fashion week,${SITE_NAME}`.replace(/^,/, '')
const saveLabel = t("save") const saveLabel = t("save")
const savedLabel = t("saved") const savedLabel = t("saved")
--- ---
<Layout> <Layout title={detailTitle} description={detailDesc} keywords={detailKeywords}>
{article ? <Item article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnap snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />} {article ? <RunwayLookCard article={article} error={articleErr} preview={preview} imageTotal={imageTotal} /> : <StreetSnapCard snap={snap} error={snapErr} preview={preview} imageTotal={imageTotal} />}
</Layout> </Layout>
<style> <style>

View File

@ -5,6 +5,7 @@
import '@/styles/global.css' import '@/styles/global.css'
import { getI18n } from '@/lib/i18n' import { getI18n } from '@/lib/i18n'
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { config } from '@/lib/config'
import { LOCALE_NAMES } from '@/lib/i18n' import { LOCALE_NAMES } from '@/lib/i18n'
import { i18n } from 'astro:config/client'; import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n'; import { toCodes } from 'astro:i18n';
@ -26,7 +27,14 @@ const failLabel = t('login failed')
const membersLabel = t('members') const membersLabel = t('members')
const backLabel = t('back to site') const backLabel = t('back to site')
const homeUrl = href(locale, ROUTES.runwayLooks) const homeUrl = href(locale, ROUTES.runwayLooks)
const pageTitle = `${loginLabel} — Portfolio Studio` const SITE_NAME = config.siteName ?? "Xisoa"
const pageTitle = `${loginLabel} — ${SITE_NAME}`
const loginDesc = locale === 'cn'
? `登录 ${SITE_NAME} 以收藏造型、同步浏览历史。`
: `Log in to ${SITE_NAME} to save looks and sync your view history.`
const loginKeywords = locale === 'cn'
? `登录,账户,${SITE_NAME}`
: `login,account,${SITE_NAME}`
--- ---
<!doctype html> <!doctype html>
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}> <html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
@ -35,6 +43,8 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title> <title>{pageTitle}</title>
<meta name="description" content={loginDesc} />
<meta name="keywords" content={loginKeywords} />
</head> </head>
<!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 --> <!-- 全站一致:白底 + 衬线大标题 + 等宽小标 + 粗黑细线,直角无阴影 -->
<body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white"> <body class="min-h-screen bg-white text-black antialiased selection:bg-black selection:text-white">

View File

@ -1,5 +1,4 @@
--- ---
import Breadcrumb from "@/components/Breadcrumb.astro"
import BrandModal from "@/components/BrandModal.astro" import BrandModal from "@/components/BrandModal.astro"
import { getSsgHotBrands } from "@/lib/ssg" import { getSsgHotBrands } from "@/lib/ssg"
import { getI18n } from '@/lib/i18n' import { getI18n } from '@/lib/i18n'
@ -68,10 +67,6 @@ const seasonOptions: { value: string; label: string }[] = [
<!-- 顶部:极简眉头 --> <!-- 顶部:极简眉头 -->
<header class="showshead"> <header class="showshead">
<div> <div>
<Breadcrumb>
<span slot="current">{t('Runway looks')}</span>
</Breadcrumb>
<h1 class="showsh1">{t('all runway looks')}</h1> <h1 class="showsh1">{t('all runway looks')}</h1>
</div> </div>
</header> </header>
@ -212,7 +207,7 @@ const seasonOptions: { value: string; label: string }[] = [
<!-- Card List --> <!-- Card List -->
<template x-for="it in items" :key="it.id"> <template x-for="it in items" :key="it.id">
<div class="showscard-wrap relative group"> <div class="showscard-wrap relative group">
<a class="showscard" :href="itemHref(it)" :aria-label="cardTitle(it)"> <a class="showscard" :href="itemHref(it)" :aria-label="cardTitle(it)" target="_blank" rel="noopener">
<div class="showscard-left"> <div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }"> <div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span> <span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
@ -269,7 +264,7 @@ const seasonOptions: { value: string; label: string }[] = [
import { toAbs } from "@/lib/request" import { toAbs } from "@/lib/request"
import { getUser } from "@/lib/auth" import { getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid" import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/ui"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites" import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {

View File

@ -1,5 +1,4 @@
--- ---
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from '@/lib/i18n' import { getI18n } from '@/lib/i18n'
import "@/styles/look-grid.css" import "@/styles/look-grid.css"
@ -49,9 +48,6 @@ const cityOptions: { value: string; label: string }[] = [
<!-- 顶部:极简眉头 --> <!-- 顶部:极简眉头 -->
<header class="snapshead"> <header class="snapshead">
<div> <div>
<Breadcrumb>
<span slot="current">{t('Street Style')}</span>
</Breadcrumb>
<h1 class="snapsh1">{t('All Street Snaps')}</h1> <h1 class="snapsh1">{t('All Street Snaps')}</h1>
</div> </div>
</header> </header>
@ -170,7 +166,7 @@ const cityOptions: { value: string; label: string }[] = [
<div class="snapsgrid"> <div class="snapsgrid">
<template x-for="it in items" :key="it.id"> <template x-for="it in items" :key="it.id">
<div class="showscard-wrap relative group"> <div class="showscard-wrap relative group">
<a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)"> <a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)" target="_blank" rel="noopener">
<div class="snapscard-left"> <div class="snapscard-left">
<div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }"> <div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG"></span> <span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
@ -225,7 +221,7 @@ const cityOptions: { value: string; label: string }[] = [
import { toAbs } from "@/lib/request" import { toAbs } from "@/lib/request"
import { getUser } from "@/lib/auth" import { getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid" import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/ui"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites" import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
const DEFAULT_SORT = "image_count" const DEFAULT_SORT = "image_count"

View File

@ -1,17 +1,37 @@
--- ---
import '@/styles/global.css' import '@/styles/global.css'
import LoadingScreen from '@/components/LoadingScreen.astro' import LoadingOverlay from '@/components/LoadingOverlay.astro'
import AuthModal from '@/components/AuthModal.astro' import AuthModal from '@/components/AuthModal.astro'
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { getI18n, LOCALE_NAMES, setApiLocale } from '@/lib/i18n' import { getI18n, LOCALE_NAMES, setApiLocale } from '@/lib/i18n'
import { config } from '@/lib/config'
import { i18n } from 'astro:config/client'; import { i18n } from 'astro:config/client';
import { toCodes } from 'astro:i18n'; import { toCodes } from 'astro:i18n';
const { title } = Astro.props as { title?: string } interface Props {
const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio" title?: string
description?: string
keywords?: string
}
const { title, description, keywords } = Astro.props
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro) const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
const SITE_NAME = config.siteName ?? "Xisoa"
const siteTitle = title ? `${title} — ${SITE_NAME}` : SITE_NAME
// 默认 TDK(按语言);各页面可经 Layout 的 description/keywords 覆盖。
const defaultDescription =
locale === 'cn'
? `${SITE_NAME} 时尚档案收录全球时装周走秀造型与街头抓拍,可按品牌、季节、城市检索。`
: `${SITE_NAME} is a fashion archive of runway looks and street style snaps — browse by brand, season and city.`
const defaultKeywords =
locale === 'cn'
? "时尚档案,走秀,街拍,时装周,品牌,季节"
: "fashion archive,runway looks,street style,fashion week,brand,season"
const metaDescription = description ?? defaultDescription
const metaKeywords = keywords ?? defaultKeywords
// 把当前语言注入 API 请求层:所有服务端(SSG/SSR)取数统一带上 ?locale=, // 把当前语言注入 API 请求层:所有服务端(SSG/SSR)取数统一带上 ?locale=,
// 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。 // 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。
setApiLocale(locale) setApiLocale(locale)
@ -60,15 +80,18 @@ const headers = {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{siteTitle}</title> <title>{siteTitle}</title>
<meta name="description" content={metaDescription} />
<meta name="keywords" content={metaKeywords} />
</head> </head>
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans"> <body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
<!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingScreen.astro --> <!-- 加载动画屏:独立组件,标记/样式/隐藏逻辑封装在 LoadingScreen.astro -->
<LoadingScreen /> <LoadingOverlay />
<!-- 顶部主导航栏 --> <!-- 顶部主导航栏 -->
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase"> <nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase">
<div class="flex flex-wrap items-center gap-6 md:gap-8"> <div class="flex flex-wrap items-center gap-6 md:gap-8">
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Home')}</a>
<a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent"> <a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">
{t('Portfolio')} {t('Portfolio')}
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none"> <span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">

View File

@ -9,6 +9,7 @@ const config: AppConfig = {
// 构建期 SSG 内部端口:绑定回环,绝不对公网暴露。 // 构建期 SSG 内部端口:绑定回环,绝不对公网暴露。
baseApiSsg: "http://127.0.0.1:8091", baseApiSsg: "http://127.0.0.1:8091",
idleLogoutDays: 7, // 超过 7 天没访问则登出(0 = 关闭空闲登出) idleLogoutDays: 7, // 超过 7 天没访问则登出(0 = 关闭空闲登出)
siteName: "Xisoa", // 站点名(TDK 的 title 后缀 / description 前缀统一来源)
} }
export default config export default config

View File

@ -1,9 +1,10 @@
/** /**
* 列表页 / 账户页共享的 Alpine 工具(从 RunwayLooks.astro / StreetSnaps.astro / account.astro 抽出)。 * 展示层共享工具(从 RunwayLooks.astro / StreetSnaps.astro / AccountPage.astro 抽出)。
* 仅承载同构、且与各自筛选项无关的逻辑:加载转圈 SVG、卡片缩略图槽位、分页页码计算。 * 仅承载同构、且与各自筛选项无关的逻辑:加载转圈 SVG、卡片缩略图槽位、分页页码计算。
* 页面特有逻辑(品牌弹窗、各筛选维度、取数 API)仍保留在各组件内。 * 页面特有逻辑(品牌弹窗、各筛选维度、取数 API)仍保留在各组件内。
*/ */
// loading 图
export const SPIN_SVG = export const SPIN_SVG =
`<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block">` + `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block">` +
`<g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g>` + `<g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g>` +

View File

@ -1,6 +1,6 @@
--- ---
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/AccountPage.astro(en/cn 共用)。 // 语言由 Astro.currentLocale 运行时决定,实现见 components/views/AccountPage.astro(en/cn 共用)。
import AccountPage from "@/components/pages/AccountPage.astro" import AccountPage from "@/components/views/AccountPage.astro"
--- ---
<AccountPage /> <AccountPage />

View File

@ -1,8 +1,8 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import Index from '@/components/Index.astro' import Home from '@/components/views/Home.astro'
--- ---
<Layout> <Layout title="时尚档案" description="Xisoa 时尚档案收录全球时装周走秀造型与街头抓拍,可按品牌、季节、城市检索。" keywords="时尚档案,走秀,街拍,时装周,品牌,季节">
<Index /> <Home />
</Layout> </Layout>

View File

@ -1,9 +1,9 @@
--- ---
export const prerender = false export const prerender = false
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/ItemPage.astro(en/cn 共用)。 // 语言由 Astro.currentLocale 运行时决定,实现见 components/views/ItemPage.astro(en/cn 共用)。
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。 // id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
import ItemPage from "@/components/pages/ItemPage.astro" import ItemPage from "@/components/views/ItemPage.astro"
--- ---
<ItemPage id={Astro.params.id} /> <ItemPage id={Astro.params.id} />

View File

@ -1,6 +1,6 @@
--- ---
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/LoginPage.astro(en/cn 共用)。 // 语言由 Astro.currentLocale 运行时决定,实现见 components/views/LoginPage.astro(en/cn 共用)。
import LoginPage from "@/components/pages/LoginPage.astro" import LoginPage from "@/components/views/LoginPage.astro"
--- ---
<LoginPage /> <LoginPage />

View File

@ -1,7 +1,7 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import RunwayLooks from '@/components/RunwayLooks.astro' import RunwayLooks from '@/components/views/RunwayLooks.astro'
--- ---
<Layout> <Layout title="走秀造型" description="浏览全球时装周走秀造型,按品牌、季节与年份筛选 —— Xisoa 时尚档案。" keywords="走秀,时装周,造型,品牌,季节,系列">
<RunwayLooks /> <RunwayLooks />
</Layout> </Layout>

View File

@ -1,7 +1,7 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import StreetSnaps from '@/components/StreetSnaps.astro' import StreetSnaps from '@/components/views/StreetSnaps.astro'
--- ---
<Layout> <Layout title="街拍" description="来自全球城市的街头抓拍,按城市与年份浏览 —— Xisoa 时尚档案。" keywords="街拍,街头风格,时尚,城市,抓拍">
<StreetSnaps /> <StreetSnaps />
</Layout> </Layout>

View File

@ -1,6 +1,6 @@
--- ---
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/AccountPage.astro(en/cn 共用)。 // 语言由 Astro.currentLocale 运行时决定,实现见 components/views/AccountPage.astro(en/cn 共用)。
import AccountPage from "@/components/pages/AccountPage.astro" import AccountPage from "@/components/views/AccountPage.astro"
--- ---
<AccountPage /> <AccountPage />

View File

@ -1,8 +1,8 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import Index from '@/components/Index.astro' import Home from '@/components/views/Home.astro'
--- ---
<Layout> <Layout title="Fashion Archive" description="Xisoa is a fashion archive of runway looks and street style snaps — browse by brand, season and city." keywords="fashion archive,runway looks,street style,fashion week,brand,season">
<Index /> <Home />
</Layout> </Layout>

View File

@ -1,9 +1,9 @@
--- ---
export const prerender = false export const prerender = false
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/ItemPage.astro(en/cn 共用)。 // 语言由 Astro.currentLocale 运行时决定,实现见 components/views/ItemPage.astro(en/cn 共用)。
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。 // id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
import ItemPage from "@/components/pages/ItemPage.astro" import ItemPage from "@/components/views/ItemPage.astro"
--- ---
<ItemPage id={Astro.params.id} /> <ItemPage id={Astro.params.id} />

View File

@ -1,6 +1,6 @@
--- ---
// 语言由 Astro.currentLocale 运行时决定,实现见 components/pages/LoginPage.astro(en/cn 共用)。 // 语言由 Astro.currentLocale 运行时决定,实现见 components/views/LoginPage.astro(en/cn 共用)。
import LoginPage from "@/components/pages/LoginPage.astro" import LoginPage from "@/components/views/LoginPage.astro"
--- ---
<LoginPage /> <LoginPage />

View File

@ -1,7 +1,7 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import RunwayLooks from '@/components/RunwayLooks.astro' import RunwayLooks from '@/components/views/RunwayLooks.astro'
--- ---
<Layout> <Layout title="Runway Looks" description="Browse runway looks from fashion weeks around the world — filter by brand, season and year on Xisoa." keywords="runway looks,fashion week,runway,brand,season,collection">
<RunwayLooks /> <RunwayLooks />
</Layout> </Layout>

View File

@ -1,7 +1,7 @@
--- ---
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import StreetSnaps from '@/components/StreetSnaps.astro' import StreetSnaps from '@/components/views/StreetSnaps.astro'
--- ---
<Layout> <Layout title="Street Style" description="Street style snaps from cities around the world — browse by city and year on Xisoa." keywords="street style,street snaps,fashion,street photography,city">
<StreetSnaps /> <StreetSnaps />
</Layout> </Layout>

View File

@ -4,9 +4,9 @@
// 若需强制跳 /en,应在此加 redirect 或 middleware —— 当前保持现状,未做跳转。 // 若需强制跳 /en,应在此加 redirect 或 middleware —— 当前保持现状,未做跳转。
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import Index from '@/components/Index.astro' import Home from '@/components/views/Home.astro'
--- ---
<Layout> <Layout title="Fashion Archive" description="Xisoa is a fashion archive of runway looks and street style snaps — browse by brand, season and city." keywords="fashion archive,runway looks,street style,fashion week,brand,season">
<Index /> <Home />
</Layout> </Layout>