50
.workbuddy/memory/2026-08-16.md
Normal file
50
.workbuddy/memory/2026-08-16.md
Normal file
@ -0,0 +1,50 @@
|
||||
# 2026-08-16
|
||||
|
||||
## i18n 取语言方式重构
|
||||
- 项目 i18n 已配置(defaultLocale: 'en', locales: ['en','cn'], prefixDefaultLocale: false)。
|
||||
- 将 `.astro` 页面里 `getLocaleFromUrl(Astro.url)` 改为 `Astro.currentLocale`(Astro 路由解析,更稳健),并补类型兜底 `(Astro.currentLocale as Locale) ?? DEFAULT_LOCALE`。
|
||||
- 涉及文件:src/layouts/Layout.astro、src/pages/index.astro、src/pages/cn/index.astro。
|
||||
- src/i18n/utils.ts 的 `getLocaleFromUrl` 纯函数保留(供 .ts 模块或解析任意 URL 使用)。
|
||||
## 结论
|
||||
- `Astro.currentLocale` 可替代 `getLocaleFromUrl(Astro.url)`,但仅限 .astro 文件;纯 TS / 解析非当前页 URL 仍需自定义函数。
|
||||
|
||||
## Astro 浏览器语言检测(重要,修正此前误解)
|
||||
- Astro 确实提供 `Astro.preferredLocale` / `Astro.preferredLocaleList`,但它是**服务端按请求**结合 `Accept-Language` 头计算的,仅 SSR 运行时有效。
|
||||
- **本项目是静态构建 + nginx 托管**:build 阶段无访客、无 Accept-Language 头,运行时也无 Node 进程重算,故 `preferredLocale` 在静态产物里是 undefined、`preferredLocaleList` 回退为全部 i18n.locales,**无法在运行时检测访客语言**。
|
||||
- 客户端 `navigator.language` 脚本仍是当前静态部署下唯一运行时生效的方案。
|
||||
- 若要改用 `Astro.preferredLocale` + `Astro.redirect()`:必须迁移到 SSR(加 adapter、output:'server'、nginx 后跑 Node),且需在 i18n.locales 为 `cn` 补 `codes: ['zh','zh-CN','zh-TW','zh-HK']` 才能把浏览器 `zh-CN` 匹配到 `cn`(当前 `locales:['en','cn']` 裸码匹配不到)。
|
||||
|
||||
## Bug 修复:header 语言切换器不显示
|
||||
- 根因:`src/layouts/Layout.astro` 中语言切换器(`LOCALES.map`)原是 `#auth-slot` 的子节点;页脚 auth 脚本 `fetchMe()` 未登录返回 null → `render(null)` 用 `slot.innerHTML` 整块重写成 Login/Signup,把语言切换器一并抹掉。任何未登录访客都触发,故语言选项永远看不见。
|
||||
- 修复:(1) 将语言切换器移出 `#auth-slot`,独立为 `#lang-switch` 兄弟节点,脚本重写不再波及;(2) 未登录时不再硬编码 `/login` 重写 `#auth-slot`,保留 SSR 的带语言前缀 Login/Signup,顺带修好 /cn 页登录链接丢前缀问题。
|
||||
- 教训:header 中「语言切换器」与「登录态 UI」必须分属不同容器,避免被 auth 脚本的 innerHTML 重写误伤。
|
||||
|
||||
## 语言切换器改为下拉菜单
|
||||
- 将 header `#lang-switch` 从「English / 中文」并排链接改为 Alpine 下拉(`x-data="{open:false}"` + `@click.outside` + `@keydown.escape.window`,绝对定位 `right-0`、`z-50`,带 chevron 旋转)。选中项 `font-bold`,其余 `opacity-60`。
|
||||
- 在 `src/styles/global.css` 补 `[x-cloak]{display:none!important}`(原仅 shows/index 各自 `<style>` 有,Layout 作为全站模板缺此规则会导致下拉闪烁)。
|
||||
- 移除原链接间的分隔斜杠 `<span class="opacity-25">/</span>`。
|
||||
- 仍保留独立 `#lang-switch` 容器,不被 footer auth 脚本重写。
|
||||
|
||||
## 语言切换器升级:地球图标 + hover 展开
|
||||
- 把按钮内容从 `{LOCALE_NAMES[locale]} + chevron` 换成地球 SVG 图标(`w-[18px] h-[18px]`,圆+经线+纬线),移除文本标签和 chevron。
|
||||
- 行为从「click 切换」改为「hover 展开」,Alpine 加 `@mouseenter="open=true"` + `@mouseleave="open=false"`;click 仍保留(`@click="open=!open"`)作为触屏兜底。
|
||||
- 关键坑:按钮与下拉之间的 `mt-2` 空隙会让鼠标从按钮移到下拉时 `mouseleave` 先触发、面板被收起。改为 `top-full`(紧贴按钮,无外间距),且整个切换器(图标+菜单)作为同一 Alpine 容器,鼠标在内部移动不会触发关闭。
|
||||
- 加 `x-transition` 做淡入淡出(150ms / 100ms)+ `py-2` 给菜单项留呼吸空间。
|
||||
- 后续补充:按钮在地球图标后追加当前语言名文字 `{LOCALE_NAMES[locale]}`(`normal-case text-[12px] tracking-[0.5px]` + `gap-1.5`),即「图标 + 当前语言」显示。
|
||||
|
||||
## i18n URL 转换改用 astro:i18n 内置函数
|
||||
- `src/i18n/utils.ts` 的 `useTranslatedPath` 内部改为委托 `astro:i18n` 的 `getRelativeLocaleUrl(targetLocale, clean)`,自动遵循 i18n 配置(prefixDefaultLocale:false → 默认语言无前缀)。
|
||||
- 同文件 `getLocaleFromUrl` 改为委托 `getLocaleByPath(pathname)`(保留 LOCALES 校验 + DEFAULT_LOCALE 兜底,try/catch)。
|
||||
- `astro:i18n` 可在 `.ts` 模块(utils.ts)中 import,前端 frontmatter 同样可用;项目 astro ^7.1.6 支持。
|
||||
- 顺手修复首页语言切换器瑕疵:`Layout.astro` 的 `currentPath` 原只剥离语言前缀,未去尾随 `index.html`,导致首页生成 `/index.html`、`/cn/index.html`;改为额外 `if 末段==='index.html' 去掉`,现首页正确生成 `/` 与 `/cn`。`currentPath` 仅用于语言切换器,改动只影响切换器。
|
||||
- 验证:构建通过(exit 0),产物 dist/index.html 语言切换器 href 为 `/` 与 `/cn`(首页)、`/shows` 与 `/cn/shows`(其他页)。
|
||||
|
||||
## 彻底去掉 useTranslatedPath 包装层
|
||||
- `src/i18n/utils.ts`:删除 `useTranslatedPath` 函数,移除不再使用的 `getRelativeLocaleUrl` 导入(仅 useTranslatedPath 引用过)。`getLocaleFromUrl` 仍委托 `getLocaleByPath`。
|
||||
- `Layout.astro`:frontmatter 直接 `import { getRelativeLocaleUrl } from 'astro:i18n'`,删除 `const translatePath = useTranslatedPath(locale)`;所有调用点(nav 5 项 + 语言切换器 `getRelativeLocaleUrl(l, currentPath)` + footer 登录/注册/About/Contact)直接改为 `getRelativeLocaleUrl(locale, path)`。行为与原先一致。
|
||||
|
||||
## Bug 修复:中文首页切换器链接错误(format:'file' 坑)
|
||||
- 站点 `build.format:'file'`,中文首页路由 /cn/ 产物是 `dist/cn.html`(英文首页是 `index.html`)。构建期 `Astro.url.pathname` 取的是产物路径 `/cn.html`。
|
||||
- 原 currentPath 只剥离 `index.html`,不处理 `cn.html`,导致中文首页 currentPath=`/cn.html`,切换器英文项误生成 `/cn.html`(实际是中文首页)、中文项 `/cn/cn.html`。
|
||||
- 修复:把"去尾随 index.html"改为"去尾随任意 .html"(`_last.endsWith('.html')` 则去掉末段)。现中英文首页切换器均正确:index.html → `/`/`/cn`;cn.html → `/`/`/cn`。
|
||||
- 教训:`format:'file'` 下 Astro.url.pathname 是产物文件名(含 .html),做"当前逻辑路径"推导时必须剥 .html 后缀,不能只认 index.html。
|
||||
@ -15,12 +15,12 @@ export default defineConfig({
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'cn'],
|
||||
routing: {
|
||||
prefixDefaultLocale: false, // 英文在根路径 /shows,中文在 /cn/shows
|
||||
prefixDefaultLocale: true, // 英文在根路径 /shows,中文在 /cn/shows
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// 示例:在构建过程中生 成`page.html` 而不是 `page/index.html`。
|
||||
format: 'file'
|
||||
// format: 'file'
|
||||
},
|
||||
integrations: [alpinejs()]
|
||||
});
|
||||
340
src/components/Index.astro
Normal file
340
src/components/Index.astro
Normal file
@ -0,0 +1,340 @@
|
||||
|
||||
---
|
||||
import ComingSoon from "./ComingSoon.astro"
|
||||
import { getShows, seriesLabel } from "../lib/data"
|
||||
import { useTranslations, DEFAULT_LOCALE, type Locale } from "../i18n/utils"
|
||||
|
||||
// SSR 预取走秀数据
|
||||
const shows = await getShows(20)
|
||||
|
||||
// 国际化:取当前页面语言(Astro 路由解析)与翻译函数
|
||||
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE
|
||||
const { t } = useTranslations(locale)
|
||||
|
||||
type GalleryItem = {
|
||||
src: string;
|
||||
brand: string;
|
||||
articleId: number;
|
||||
year: number;
|
||||
title: string;
|
||||
collection_type?: 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,
|
||||
})
|
||||
}
|
||||
---
|
||||
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
|
||||
<!-- 二级分类标签页 -->
|
||||
<div id="tabbar" class="mt-16 md:mt-24">
|
||||
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'backstage' }"
|
||||
@click="activeTab = 'backstage'"
|
||||
>
|
||||
A
|
||||
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'shows' }"
|
||||
@click="activeTab = 'shows'"
|
||||
>
|
||||
B
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'events' }"
|
||||
@click="activeTab = 'events'"
|
||||
>
|
||||
C
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'street' }"
|
||||
@click="activeTab = 'street'"
|
||||
>
|
||||
D
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 中间内容展示区 -->
|
||||
<main class="grow min-h-[400px] mt-10">
|
||||
<!-- 发布会 面板(默认激活) -->
|
||||
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
|
||||
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
|
||||
|
||||
<!-- 横向滚动画廊 -->
|
||||
{
|
||||
galleryItems.length > 0 && (
|
||||
<section class="hs-section">
|
||||
<div class="hs-stage">
|
||||
<div class="swiper hs-swiper" id="hsSwiper">
|
||||
<div class="swiper-wrapper">
|
||||
{
|
||||
galleryItems.map((g, i) => (
|
||||
<div class="swiper-slide hs-slide">
|
||||
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
|
||||
<!-- Alpine 管理单张图片的加载/失败状态 -->
|
||||
<div
|
||||
class="hs-img-box"
|
||||
x-data="{ loaded: false, error: false }"
|
||||
:class="{ 'is-loaded': loaded || error }"
|
||||
>
|
||||
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
|
||||
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
|
||||
</svg>
|
||||
</span>
|
||||
<img
|
||||
src={g.src}
|
||||
alt={g.brand}
|
||||
loading={i < 4 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
:class="{ 'is-error': error }"
|
||||
@load="loaded = true; $dispatch('img-ready')"
|
||||
@error="error = true; $dispatch('img-ready')"
|
||||
/>
|
||||
<span class="hs-caption">
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hs-controls flex justify-between items-center text-[13px] font-bold">
|
||||
<div class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Previous')}</div>
|
||||
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 灵感 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
|
||||
<!-- 社媒 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
|
||||
<!-- 街拍 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<style>
|
||||
@reference "tailwindcss";
|
||||
|
||||
:root { --brand: #cf5a44; }
|
||||
|
||||
/* 标签基础与激活样式 */
|
||||
.tab {
|
||||
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
|
||||
}
|
||||
.tab:hover { @apply text-[#666]; }
|
||||
.tab::after {
|
||||
content: "";
|
||||
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
|
||||
}
|
||||
.tab.is-active { @apply text-black; }
|
||||
.tab.is-active::after { @apply scale-x-100; }
|
||||
|
||||
/* 防闪烁标签 */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* 区块标题 */
|
||||
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
|
||||
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
|
||||
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
|
||||
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
|
||||
.sec-more:hover { @apply opacity-60; }
|
||||
|
||||
/* 横向画廊样式 */
|
||||
.hs-section { @apply mb-9; }
|
||||
.hs-stage { @apply relative; }
|
||||
.hs-controls {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
z-index: 6;
|
||||
padding: 0 8px;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: difference;
|
||||
color: #fff;
|
||||
}
|
||||
.hs-controls .hs-arrow { pointer-events: auto; }
|
||||
.hs-arrow { @apply select-none; }
|
||||
.hs-arrow.swiper-button-disabled { pointer-events: none; }
|
||||
|
||||
/* Swiper 尺寸规整 */
|
||||
.hs-swiper {
|
||||
width: 100%;
|
||||
height: clamp(500px, 80vh, 900px);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.hs-swiper .swiper-wrapper { align-items: center; }
|
||||
.hs-swiper .hs-slide {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.hs-item {
|
||||
display: block;
|
||||
height: auto;
|
||||
width: max-content;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.hs-img-box {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: clamp(500px, 80vh, 900px);
|
||||
/* aspect-ratio: 3 / 4; */
|
||||
background: #f0f0f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hs-img-box > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded > img { opacity: 1; }
|
||||
.hs-img-box > img.is-error { opacity: 0 !important; }
|
||||
|
||||
/* 加载骨架屏与旋转 Spinner */
|
||||
.hs-skel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
|
||||
background-size: 200% 100%;
|
||||
animation: hs-shimmer 1.3s ease-in-out infinite;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
|
||||
|
||||
.hs-spin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #bdbdbd;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
|
||||
.hs-spin svg { display: block; }
|
||||
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
|
||||
|
||||
@keyframes hs-rotate { to { transform: rotate(360deg); } }
|
||||
@keyframes hs-shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
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);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
z-index: 3;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hs-item:hover .hs-caption { opacity: 1; }
|
||||
.hs-cap-brand {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hs-cap-meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Swiper from "swiper";
|
||||
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/free-mode";
|
||||
|
||||
// 初始化 Swiper 轮播
|
||||
const el = document.getElementById("hsSwiper");
|
||||
if (el) {
|
||||
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
|
||||
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
|
||||
|
||||
const swiper = new Swiper(el, {
|
||||
modules: [Navigation, FreeMode, Keyboard],
|
||||
slidesPerView: "auto",
|
||||
spaceBetween: 4,
|
||||
grabCursor: true,
|
||||
speed: 220,
|
||||
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
|
||||
keyboard: { enabled: true },
|
||||
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
|
||||
observer: true,
|
||||
observeSlideChildren: true,
|
||||
});
|
||||
|
||||
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
|
||||
window.addEventListener("img-ready", () => {
|
||||
requestAnimationFrame(() => swiper.update());
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@ -1,35 +1,19 @@
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
PREFIX_DEFAULT_LOCALE,
|
||||
isLocaleSegment,
|
||||
type Locale,
|
||||
} from './config';
|
||||
import { getLocaleByPath } from 'astro:i18n';
|
||||
import { DEFAULT_LOCALE, LOCALES, type Locale } from './config';
|
||||
import { translate } from './dictionary';
|
||||
|
||||
/**
|
||||
* 从当前 URL 解析语言:
|
||||
* - 路径首段若是支持的语言前缀(如 /cn/shows → cn)则用之;
|
||||
* - 否则回退到默认语言(en)。
|
||||
* 从 URL 解析语言,委托 Astro 内置的 astro:i18n.getLocaleByPath。
|
||||
* 无前缀(默认语言)路径会解析回 DEFAULT_LOCALE。
|
||||
*/
|
||||
export function getLocaleFromUrl(url: URL | string): Locale {
|
||||
const pathname = typeof url === 'string' ? url : url.pathname;
|
||||
const seg = pathname.split('/').filter(Boolean)[0];
|
||||
return isLocaleSegment(seg) ? seg : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带语言前缀的链接。
|
||||
* - en(默认且 PREFIX_DEFAULT_LOCALE=false)→ /shows
|
||||
* - cn → /cn/shows
|
||||
* 第二个参数可指定目标语言,方便做语言切换器。
|
||||
*/
|
||||
export function useTranslatedPath(locale: Locale) {
|
||||
return (path: string, targetLocale: Locale = locale): string => {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
if (targetLocale === DEFAULT_LOCALE && !PREFIX_DEFAULT_LOCALE) return clean;
|
||||
return `/${targetLocale}${clean}`;
|
||||
};
|
||||
try {
|
||||
const locale = getLocaleByPath(pathname);
|
||||
return (LOCALES as readonly string[]).includes(locale) ? (locale as Locale) : DEFAULT_LOCALE;
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
import '../styles/global.css'
|
||||
import { getLocaleFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils'
|
||||
import { getRelativeLocaleUrl } from 'astro:i18n'
|
||||
import { useTranslations, DEFAULT_LOCALE, type Locale } from '../i18n/utils'
|
||||
import { LOCALES, LOCALE_NAMES, isLocaleSegment } from '../i18n/config'
|
||||
|
||||
const {
|
||||
@ -10,24 +11,19 @@ const {
|
||||
activeThemePalette = null,
|
||||
} = Astro.props;
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url)
|
||||
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE
|
||||
const { t } = useTranslations(locale)
|
||||
const translatePath = useTranslatedPath(locale)
|
||||
|
||||
// 当前路径去掉语言前缀,用于语言切换器拼出「同页面、另一语言」的链接
|
||||
const _segs = Astro.url.pathname.split('/').filter(Boolean);
|
||||
const _stripped = _segs.length > 0 && isLocaleSegment(_segs[0])
|
||||
? '/' + _segs.slice(1).join('/')
|
||||
: Astro.url.pathname;
|
||||
const currentPath = _stripped === '' ? '/' : _stripped;
|
||||
|
||||
const categories: { label: string; value: PortfolioCategory | 'Trending Colors' }[] = [
|
||||
{ label: 'x', value: 'x' },
|
||||
{ label: 'Backstage', value: 'Backstage' },
|
||||
{ label: 'Events', value: 'Events' },
|
||||
{ label: 'Street', value: 'Street' },
|
||||
{ label: 'Trending Colors', value: 'Trending Colors' },
|
||||
];
|
||||
// 当前路径去掉语言前缀与尾随 .html(format:'file' 产物如 cn.html / shows.html),
|
||||
// 用于语言切换器拼出「同页面、另一语言」的链接(逻辑路径,不带 .html)
|
||||
// 例:/cn.html → / ,/cn/shows.html → /shows ,/shows.html → /shows
|
||||
let _segs = Astro.url.pathname.split('/').filter(Boolean);
|
||||
if (_segs[0] && isLocaleSegment(_segs[0])) _segs = _segs.slice(1);
|
||||
const _last = _segs[_segs.length - 1];
|
||||
if (_last && _last.endsWith('.html')) _segs = _segs.slice(0, -1);
|
||||
const currentPath = _segs.length === 0 ? '/' : '/' + _segs.join('/');
|
||||
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
|
||||
@ -61,31 +57,70 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
|
||||
<!-- 顶部主导航栏 -->
|
||||
<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">
|
||||
<a href={translatePath('/portfolio')} 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')}<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
|
||||
<a href={getRelativeLocaleUrl(locale, '/portfolio')} 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')}<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
|
||||
2026
|
||||
</span></a>
|
||||
<a href={translatePath('/shows')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('All Shows')}</a>
|
||||
<a href={translatePath('/latest-projects')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a>
|
||||
<a href={translatePath('/about')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a>
|
||||
<a href={translatePath('/contact')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
|
||||
<a href={getRelativeLocaleUrl(locale, '/shows')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('All Shows')}</a>
|
||||
<a href={getRelativeLocaleUrl(locale, '/latest-projects')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a>
|
||||
<a href={getRelativeLocaleUrl(locale, '/about')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a>
|
||||
<a href={getRelativeLocaleUrl(locale, '/contact')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3" id="auth-slot">
|
||||
<div class="flex items-center gap-2 normal-case" aria-label="Language">
|
||||
{LOCALES.map((l) => (
|
||||
<a
|
||||
href={translatePath(currentPath, l)}
|
||||
aria-current={l === locale ? 'true' : undefined}
|
||||
class:list={[
|
||||
'cursor-pointer hover:opacity-60 transition-opacity duration-200',
|
||||
l === locale ? 'opacity-100 underline underline-offset-4' : 'opacity-40',
|
||||
]}
|
||||
>{LOCALE_NAMES[l]}</a>
|
||||
))}
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 语言切换器:Alpine 下拉,hover 展开;独立容器避免被 auth 脚本 innerHTML 重写误伤 -->
|
||||
<div
|
||||
x-data="{ open: false }"
|
||||
@mouseenter="open = true"
|
||||
@mouseleave="open = false"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
class="relative"
|
||||
id="lang-switch"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[12px] tracking-[0.5px]"
|
||||
aria-label="Language"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="open"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-[18px] h-[18px]" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
<span>{LOCALE_NAMES[locale]}</span>
|
||||
</button>
|
||||
<ul
|
||||
x-show="open"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 -translate-y-1"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-1"
|
||||
class="absolute right-0 top-full min-w-[160px] bg-white border border-black/10 shadow-sm z-50 py-2"
|
||||
>
|
||||
{LOCALES.map((l) => (
|
||||
<li>
|
||||
<a
|
||||
href={getRelativeLocaleUrl(l, currentPath)}
|
||||
@click="open = false"
|
||||
class:list={[
|
||||
'block px-4 py-1.5 text-[12px] uppercase tracking-[0.5px] hover:bg-black/5 transition-colors',
|
||||
l === locale ? 'font-bold' : 'opacity-60',
|
||||
]}
|
||||
>{LOCALE_NAMES[l]}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" id="auth-slot">
|
||||
<a href={getRelativeLocaleUrl(locale, '/login')} class="hover:opacity-60 transition-opacity duration-200">{t('Login')}</a>
|
||||
<a href={getRelativeLocaleUrl(locale, '/register')} class="hover:opacity-60 transition-opacity duration-200">{t('Signup')}</a>
|
||||
</div>
|
||||
<span class="opacity-25">/</span>
|
||||
<a href={translatePath('/login')} class="hover:opacity-60 transition-opacity duration-200">{t('Login')}</a>
|
||||
<a href={translatePath('/register')} class="hover:opacity-60 transition-opacity duration-200">{t('Signup')}</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@ -98,13 +133,10 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
|
||||
import { getUser, fetchMe, clearSession } from "../lib/auth.ts"
|
||||
const slot = document.getElementById("auth-slot")
|
||||
function render(user) {
|
||||
if (user) {
|
||||
slot.innerHTML = `<span class="opacity-70">Hi, ${user.username}</span><button id="logout-btn" class="hover:opacity-60 transition-opacity cursor-pointer bg-transparent border-0 p-0 font-bold text-[13px] uppercase tracking-[0.5px]">Logout</button>`
|
||||
const btn = slot.querySelector("#logout-btn")
|
||||
if (btn) btn.addEventListener("click", () => { clearSession(); location.reload() })
|
||||
} else {
|
||||
slot.innerHTML = `<a href="/login" class="hover:opacity-60 transition-opacity duration-200">Login</a><a href="/register" class="hover:opacity-60 transition-opacity duration-200">Signup</a>`
|
||||
}
|
||||
if (!user) return // 未登录:保留 SSR 渲染的 Login/Signup(已带语言前缀,无需重写)
|
||||
slot.innerHTML = `<span class="opacity-70">Hi, ${user.username}</span><button id="logout-btn" class="hover:opacity-60 transition-opacity cursor-pointer bg-transparent border-0 p-0 font-bold text-[13px] uppercase tracking-[0.5px]">Logout</button>`
|
||||
const btn = slot.querySelector("#logout-btn")
|
||||
if (btn) btn.addEventListener("click", () => { clearSession(); location.reload() })
|
||||
}
|
||||
const cached = getUser()
|
||||
if (cached) render(cached)
|
||||
@ -140,30 +172,6 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Quick Navigation */}
|
||||
<div class="md:col-span-3 space-y-3">
|
||||
<span class="text-[11px] font-mono uppercase tracking-widest text-zinc-400 font-bold block mb-2">
|
||||
Categories
|
||||
</span>
|
||||
<ul class="space-y-2 list-none p-0 m-0 text-xs font-mono font-bold uppercase tracking-wider">
|
||||
{categories.map((cat) => (
|
||||
<li>
|
||||
<button
|
||||
data-category={cat.value}
|
||||
class={`category-btn hover:opacity-60 transition-opacity cursor-pointer flex items-center gap-2 ${
|
||||
activeCategory === cat.value ? 'underline font-black text-black' : 'text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<span>{cat.label}</span>
|
||||
{cat.value === 'Trending Colors' && (
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Column 3: Color Studio & Actions */}
|
||||
<div class="md:col-span-4 space-y-3">
|
||||
<span class="text-[11px] font-mono uppercase tracking-widest text-zinc-400 font-bold block mb-2">
|
||||
@ -239,13 +247,13 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<a
|
||||
href={translatePath('/about')}
|
||||
href={getRelativeLocaleUrl(locale, '/about')}
|
||||
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
|
||||
>
|
||||
{t('About')}
|
||||
</a>
|
||||
<a
|
||||
href={translatePath('/contact')}
|
||||
href={getRelativeLocaleUrl(locale, '/contact')}
|
||||
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
|
||||
>
|
||||
{t('Contact')}
|
||||
|
||||
@ -186,7 +186,6 @@ export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
|
||||
if (!API_BASE) return []
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=${size}&with_images=5`)
|
||||
console.log(res)
|
||||
if (!res.ok) return []
|
||||
const json: any = await res.json()
|
||||
const items: RawArticle[] = json?.data ?? []
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro"
|
||||
import About from "../components/pages/About.astro"
|
||||
---
|
||||
|
||||
<Layout title="About — 暂未上线">
|
||||
<About />
|
||||
</Layout>
|
||||
8
src/pages/cn/about.astro
Normal file
8
src/pages/cn/about.astro
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro"
|
||||
import About from "../../components/pages/About.astro"
|
||||
---
|
||||
|
||||
<Layout title="About — 暂未上线">
|
||||
<About />
|
||||
</Layout>
|
||||
@ -1,344 +1,8 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro"
|
||||
import ComingSoon from "../../components/ComingSoon.astro"
|
||||
import { getShows, seriesLabel } from "../../lib/data"
|
||||
import { getLocaleFromUrl, useTranslations } from "../../i18n/utils"
|
||||
|
||||
// SSR 预取走秀数据
|
||||
const shows = await getShows(20)
|
||||
|
||||
// 国际化:根据 URL 前缀(/cn/...)取当前语言与翻译函数
|
||||
const locale = getLocaleFromUrl(Astro.url)
|
||||
const { t } = useTranslations(locale)
|
||||
|
||||
type GalleryItem = {
|
||||
src: string;
|
||||
brand: string;
|
||||
articleId: number;
|
||||
year: number;
|
||||
title: string;
|
||||
collection_type?: 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,
|
||||
})
|
||||
}
|
||||
import Layout from '../../layouts/Layout.astro'
|
||||
import Index from '../../components/Index.astro'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
|
||||
<!-- 二级分类标签页 -->
|
||||
<div id="tabbar" class="mt-16 md:mt-24">
|
||||
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'backstage' }"
|
||||
@click="activeTab = 'backstage'"
|
||||
>
|
||||
A
|
||||
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'shows' }"
|
||||
@click="activeTab = 'shows'"
|
||||
>
|
||||
B
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'events' }"
|
||||
@click="activeTab = 'events'"
|
||||
>
|
||||
C
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'street' }"
|
||||
@click="activeTab = 'street'"
|
||||
>
|
||||
D
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 中间内容展示区 -->
|
||||
<main class="grow min-h-[400px] mt-10">
|
||||
<!-- 发布会 面板(默认激活) -->
|
||||
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
|
||||
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
|
||||
|
||||
<!-- 横向滚动画廊 -->
|
||||
{
|
||||
galleryItems.length > 0 && (
|
||||
<section class="hs-section">
|
||||
<div class="hs-stage">
|
||||
<div class="swiper hs-swiper" id="hsSwiper">
|
||||
<div class="swiper-wrapper">
|
||||
{
|
||||
galleryItems.map((g, i) => (
|
||||
<div class="swiper-slide hs-slide">
|
||||
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
|
||||
<!-- Alpine 管理单张图片的加载/失败状态 -->
|
||||
<div
|
||||
class="hs-img-box"
|
||||
x-data="{ loaded: false, error: false }"
|
||||
:class="{ 'is-loaded': loaded || error }"
|
||||
>
|
||||
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
|
||||
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
|
||||
</svg>
|
||||
</span>
|
||||
<img
|
||||
src={g.src}
|
||||
alt={g.brand}
|
||||
loading={i < 4 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
:class="{ 'is-error': error }"
|
||||
@load="loaded = true; $dispatch('img-ready')"
|
||||
@error="error = true; $dispatch('img-ready')"
|
||||
/>
|
||||
<span class="hs-caption">
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hs-controls flex justify-between items-center text-[13px] font-bold">
|
||||
<div class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Previous')}</div>
|
||||
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 灵感 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
|
||||
<!-- 社媒 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
|
||||
<!-- 街拍 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Index />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
@reference "tailwindcss";
|
||||
|
||||
:root { --brand: #cf5a44; }
|
||||
|
||||
/* 标签基础与激活样式 */
|
||||
.tab {
|
||||
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
|
||||
}
|
||||
.tab:hover { @apply text-[#666]; }
|
||||
.tab::after {
|
||||
content: "";
|
||||
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
|
||||
}
|
||||
.tab.is-active { @apply text-black; }
|
||||
.tab.is-active::after { @apply scale-x-100; }
|
||||
|
||||
/* 防闪烁标签 */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* 区块标题 */
|
||||
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
|
||||
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
|
||||
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
|
||||
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
|
||||
.sec-more:hover { @apply opacity-60; }
|
||||
|
||||
/* 横向画廊样式 */
|
||||
.hs-section { @apply mb-9; }
|
||||
.hs-stage { @apply relative; }
|
||||
.hs-controls {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
z-index: 6;
|
||||
padding: 0 8px;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: difference;
|
||||
color: #fff;
|
||||
}
|
||||
.hs-controls .hs-arrow { pointer-events: auto; }
|
||||
.hs-arrow { @apply select-none; }
|
||||
.hs-arrow.swiper-button-disabled { pointer-events: none; }
|
||||
|
||||
/* Swiper 尺寸规整 */
|
||||
.hs-swiper {
|
||||
width: 100%;
|
||||
height: clamp(500px, 80vh, 900px);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.hs-swiper .swiper-wrapper { align-items: center; }
|
||||
.hs-swiper .hs-slide {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.hs-item {
|
||||
display: block;
|
||||
height: auto;
|
||||
width: max-content;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.hs-img-box {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: clamp(500px, 80vh, 900px);
|
||||
/* aspect-ratio: 3 / 4; */
|
||||
background: #f0f0f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hs-img-box > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded > img { opacity: 1; }
|
||||
.hs-img-box > img.is-error { opacity: 0 !important; }
|
||||
|
||||
/* 加载骨架屏与旋转 Spinner */
|
||||
.hs-skel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
|
||||
background-size: 200% 100%;
|
||||
animation: hs-shimmer 1.3s ease-in-out infinite;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
|
||||
|
||||
.hs-spin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #bdbdbd;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
|
||||
.hs-spin svg { display: block; }
|
||||
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
|
||||
|
||||
@keyframes hs-rotate { to { transform: rotate(360deg); } }
|
||||
@keyframes hs-shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
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);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
z-index: 3;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hs-item:hover .hs-caption { opacity: 1; }
|
||||
.hs-cap-brand {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hs-cap-meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Swiper from "swiper";
|
||||
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/free-mode";
|
||||
|
||||
// 初始化 Swiper 轮播
|
||||
const el = document.getElementById("hsSwiper");
|
||||
if (el) {
|
||||
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
|
||||
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
|
||||
|
||||
const swiper = new Swiper(el, {
|
||||
modules: [Navigation, FreeMode, Keyboard],
|
||||
slidesPerView: "auto",
|
||||
spaceBetween: 4,
|
||||
grabCursor: true,
|
||||
speed: 220,
|
||||
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
|
||||
keyboard: { enabled: true },
|
||||
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
|
||||
observer: true,
|
||||
observeSlideChildren: true,
|
||||
});
|
||||
|
||||
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
|
||||
window.addEventListener("img-ready", () => {
|
||||
requestAnimationFrame(() => swiper.update());
|
||||
});
|
||||
}
|
||||
</script>
|
||||
8
src/pages/en/about.astro
Normal file
8
src/pages/en/about.astro
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro"
|
||||
import About from "../../components/pages/About.astro"
|
||||
---
|
||||
|
||||
<Layout title="About — 暂未上线">
|
||||
<About />
|
||||
</Layout>
|
||||
8
src/pages/en/index.astro
Normal file
8
src/pages/en/index.astro
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro'
|
||||
import Index from '../../components/Index.astro'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Index />
|
||||
</Layout>
|
||||
@ -1,344 +1,11 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro"
|
||||
import ComingSoon from "../components/ComingSoon.astro"
|
||||
import { getShows, seriesLabel } from "../lib/data"
|
||||
import { getLocaleFromUrl, useTranslations } from "../i18n/utils"
|
||||
|
||||
// SSR 预取走秀数据
|
||||
const shows = await getShows(20)
|
||||
// 应该跳转到 /en
|
||||
|
||||
// 国际化:根据 URL 前缀(/cn/...)取当前语言与翻译函数
|
||||
const locale = getLocaleFromUrl(Astro.url)
|
||||
const { t } = useTranslations(locale)
|
||||
|
||||
type GalleryItem = {
|
||||
src: string;
|
||||
brand: string;
|
||||
articleId: number;
|
||||
year: number;
|
||||
title: string;
|
||||
collection_type?: 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,
|
||||
})
|
||||
}
|
||||
import Layout from '../layouts/Layout.astro'
|
||||
import Index from '../components/Index.astro'
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
|
||||
<!-- 二级分类标签页 -->
|
||||
<div id="tabbar" class="mt-16 md:mt-24">
|
||||
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'backstage' }"
|
||||
@click="activeTab = 'backstage'"
|
||||
>
|
||||
A
|
||||
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'shows' }"
|
||||
@click="activeTab = 'shows'"
|
||||
>
|
||||
B
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'events' }"
|
||||
@click="activeTab = 'events'"
|
||||
>
|
||||
C
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<li
|
||||
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
|
||||
:class="{ 'is-active': activeTab === 'street' }"
|
||||
@click="activeTab = 'street'"
|
||||
>
|
||||
D
|
||||
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
|
||||
</li>
|
||||
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 中间内容展示区 -->
|
||||
<main class="grow min-h-[400px] mt-10">
|
||||
<!-- 发布会 面板(默认激活) -->
|
||||
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
|
||||
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
|
||||
|
||||
<!-- 横向滚动画廊 -->
|
||||
{
|
||||
galleryItems.length > 0 && (
|
||||
<section class="hs-section">
|
||||
<div class="hs-stage">
|
||||
<div class="swiper hs-swiper" id="hsSwiper">
|
||||
<div class="swiper-wrapper">
|
||||
{
|
||||
galleryItems.map((g, i) => (
|
||||
<div class="swiper-slide hs-slide">
|
||||
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
|
||||
<!-- Alpine 管理单张图片的加载/失败状态 -->
|
||||
<div
|
||||
class="hs-img-box"
|
||||
x-data="{ loaded: false, error: false }"
|
||||
:class="{ 'is-loaded': loaded || error }"
|
||||
>
|
||||
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
|
||||
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
|
||||
</svg>
|
||||
</span>
|
||||
<img
|
||||
src={g.src}
|
||||
alt={g.brand}
|
||||
loading={i < 4 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
:class="{ 'is-error': error }"
|
||||
@load="loaded = true; $dispatch('img-ready')"
|
||||
@error="error = true; $dispatch('img-ready')"
|
||||
/>
|
||||
<span class="hs-caption">
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hs-controls flex justify-between items-center text-[13px] font-bold">
|
||||
<div class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Previous')}</div>
|
||||
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- 灵感 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
|
||||
<!-- 社媒 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
|
||||
<!-- 街拍 面板 -->
|
||||
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
|
||||
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Index />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
@reference "tailwindcss";
|
||||
|
||||
:root { --brand: #cf5a44; }
|
||||
|
||||
/* 标签基础与激活样式 */
|
||||
.tab {
|
||||
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
|
||||
}
|
||||
.tab:hover { @apply text-[#666]; }
|
||||
.tab::after {
|
||||
content: "";
|
||||
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
|
||||
}
|
||||
.tab.is-active { @apply text-black; }
|
||||
.tab.is-active::after { @apply scale-x-100; }
|
||||
|
||||
/* 防闪烁标签 */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* 区块标题 */
|
||||
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
|
||||
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
|
||||
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
|
||||
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
|
||||
.sec-more:hover { @apply opacity-60; }
|
||||
|
||||
/* 横向画廊样式 */
|
||||
.hs-section { @apply mb-9; }
|
||||
.hs-stage { @apply relative; }
|
||||
.hs-controls {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
z-index: 6;
|
||||
padding: 0 8px;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: difference;
|
||||
color: #fff;
|
||||
}
|
||||
.hs-controls .hs-arrow { pointer-events: auto; }
|
||||
.hs-arrow { @apply select-none; }
|
||||
.hs-arrow.swiper-button-disabled { pointer-events: none; }
|
||||
|
||||
/* Swiper 尺寸规整 */
|
||||
.hs-swiper {
|
||||
width: 100%;
|
||||
height: clamp(500px, 80vh, 900px);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.hs-swiper .swiper-wrapper { align-items: center; }
|
||||
.hs-swiper .hs-slide {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.hs-item {
|
||||
display: block;
|
||||
height: auto;
|
||||
width: max-content;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.hs-img-box {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: clamp(500px, 80vh, 900px);
|
||||
/* aspect-ratio: 3 / 4; */
|
||||
background: #f0f0f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hs-img-box > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded > img { opacity: 1; }
|
||||
.hs-img-box > img.is-error { opacity: 0 !important; }
|
||||
|
||||
/* 加载骨架屏与旋转 Spinner */
|
||||
.hs-skel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
|
||||
background-size: 200% 100%;
|
||||
animation: hs-shimmer 1.3s ease-in-out infinite;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
|
||||
|
||||
.hs-spin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #bdbdbd;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
|
||||
.hs-spin svg { display: block; }
|
||||
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
|
||||
|
||||
@keyframes hs-rotate { to { transform: rotate(360deg); } }
|
||||
@keyframes hs-shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
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);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
z-index: 3;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hs-item:hover .hs-caption { opacity: 1; }
|
||||
.hs-cap-brand {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hs-cap-meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Swiper from "swiper";
|
||||
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/free-mode";
|
||||
|
||||
// 初始化 Swiper 轮播
|
||||
const el = document.getElementById("hsSwiper");
|
||||
if (el) {
|
||||
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
|
||||
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
|
||||
|
||||
const swiper = new Swiper(el, {
|
||||
modules: [Navigation, FreeMode, Keyboard],
|
||||
slidesPerView: "auto",
|
||||
spaceBetween: 4,
|
||||
grabCursor: true,
|
||||
speed: 220,
|
||||
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
|
||||
keyboard: { enabled: true },
|
||||
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
|
||||
observer: true,
|
||||
observeSlideChildren: true,
|
||||
});
|
||||
|
||||
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
|
||||
window.addEventListener("img-ready", () => {
|
||||
requestAnimationFrame(() => swiper.update());
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@ -1 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
Reference in New Issue
Block a user