14
.workbuddy/memory/2026-08-25.md
Normal file
14
.workbuddy/memory/2026-08-25.md
Normal file
@ -0,0 +1,14 @@
|
||||
# 2026-08-25 工作日志
|
||||
|
||||
## 新增 /en/ 登录注册页(20:02)
|
||||
- 用户要求:把 /en/ 下的 login 和 register 做出来,符合当前网站设计。
|
||||
- 背景:Astro i18n `prefixDefaultLocale: true`,导航 Login/Signup 链接解析为 `/en/login`、`/en/register`,但这两个文件之前不存在(只有根 `src/pages/login.astro` 等 ComingSoon 占位),点开 404。
|
||||
- 改动(加法式,不动根 ComingSoon 页、不动原 Login.astro/Register.astro 组件):
|
||||
1. `src/lib/api.ts`:新增 `saveSession(token, user)`,写入 `fa_token`/`fa_user`(与 `getUser`/`fetchMe` 共用 key),供登录/注册后落地本地会话。
|
||||
2. 新建 `src/components/pages/AuthForm.astro`:登录/注册通用编辑风表单(props `mode: 'login'|'register'`)。
|
||||
- 设计:沿用全站黑白极简编辑风——monospace 顶边 kicker(Account / Sign In|Create Account)、`font-serif font-black` 大标题、monospace 副标题、黑底描边 input(`auth-input`)、黑底白字描边按钮(hover 反白,沿用 `cs-btn` 调性)、底部 footer 链接(注册↔登录互链)。容器用 `w-full max-w-[460px] mx-auto`(避免 m-auto shrink-to-fit,见 MEMORY Layout 陷阱)。
|
||||
- 客户端 `<script>`(module,可 import):读 `form.dataset.mode/redirect`;提交 POST `/api/auth/login` 或 `/api/auth/register`(body JSON),成功 `saveSession` 后 `location.href = redirect`(优先 `?redirect=`,否则 `/en/`);失败/网络错在 `auth-error` 内联显示;注册时校验两次密码一致。后端不可用时优雅报错,不破坏设计。
|
||||
3. 新建 `src/pages/en/login.astro`、`src/pages/en/register.astro`:套 `<Layout>` + `<AuthForm>`。
|
||||
4. `src/layouts/Layout.astro`:原来 `<title>{`111`}</title>` 硬编码 bug,改为支持 `title` prop(`${title} — Portfolio Studio`)。属于让新页面标题正确的必要小修;导航(header)未动。
|
||||
- 验证:`npm run build` BUILD_EXIT=0;AuthForm/两个 page/Layout `read_lints` 全 0;`dist/client/en/login/index.html`、`en/register/index.html` 均含 `auth-form`/`auth-input`/对应标题;`python -m http.server 8088` 托管 dist/client,`/en/login/`、`/en/register/` 均 200。
|
||||
- ⚠️ 范围说明:仅做了 /en/ 版。根 `/login`、`/cn/login`(及 register 对应)仍是 ComingSoon 占位,未动。若要补齐 /cn/ 与根版,需再建 `src/pages/cn/login.astro` 等(可复用 AuthForm)。后端 `/api/auth/login|register` 是否就绪未知,未接时提交会显示内联错误。
|
||||
@ -5,8 +5,8 @@
|
||||
- **所有走秀页 `/shows`**(新建 2026-08-08):全局走秀档案(区别于 `/articles` 的品牌档案视图 ?brand_id)。基于 articles.astro,标题「All Runway」,保留系列/季节/品牌筛选 + 三视图(Grid/Magazine/Timeline),新增分页 Load More(page/total/busy,append 模式,后端 `?page=&size=30&with_images=5`)。首页 sec-more「全部走秀 →」指向 /shows。
|
||||
- 其余模块(Portfolio / Latest Projects / About / Contact / 登录注册)为「暂未上线」占位页:直接访问旧 URL 或点击导航旧链接均渲染 ComingSoon 占位;**导航(header)保持原样未改动**(用户 2026-08-07 明确「header 别动」),门禁完全靠占位页实现。
|
||||
- **首页 tabbar**(index.astro, id="tabbar"):仅「发布会(backstage)」上线(默认激活 + Live 徽标,承载 getShows(8) SSR 真实卡片);「灵感(shows)/社媒(events)/街拍(street)」标 `data-soon="true"` + Soon 灰徽标,点击仅显示 ComingSoon 占位、不再请求后端;「品牌」tab 仍为指向 /brands 的有效链接(属已上线模块,未屏蔽)。
|
||||
- 占位实现:src/components/ComingSoon.astro(编辑风,props: module / moduleZh / note;两个按钮「浏览走秀档案 →」「返回首页」)。6 个页面(portfolio / latest-projects / about / contact / login / register)改为 `<Layout><ComingSoon/></Layout>`。
|
||||
- login/register 原 standalone 页(未 import global.css、字体变量未定义),已改为套 Layout + ComingSoon 占位页,顺带修复字体一致性;导航中 Login/Signup 链接保留,点击即见占位。
|
||||
- 占位实现:src/components/ComingSoon.astro(编辑风,props: module / moduleZh / note;两个按钮「浏览走秀档案 →」「返回首页」)。portfolio / latest-projects / about / contact 仍为 ComingSoon 占位页。
|
||||
- **登录/注册(2026-08-25 更新)**:`/en/login`、`/en/register` 已做成真实编辑风页面(套 Layout + 新建 `src/components/pages/AuthForm.astro` 通用表单组件,`mode:'login'|'register'`,客户端 POST `/api/auth/login|register`,用 `saveSession` 写 `fa_token`/`fa_user`)。根 `/login`(src/pages/login.astro) 与 `/cn/login` 等**仍是 ComingSoon 占位**(未动,属当时范围外);导航 Login/Signup 在 en 语境下现指向真实的 /en/login、/en/register。
|
||||
|
||||
## 架构总览
|
||||
- 前端:/d/project/test/test — Astro SSG + Tailwind v4。构建 `PUBLIC_API_BASE=http://localhost:8090 npm run build`;预览 `python -m http.server` 托管 dist/。
|
||||
|
||||
@ -93,129 +93,128 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
<div class="showsbody">
|
||||
<!-- LEFT SIDEBAR -->
|
||||
<aside class="showsaside">
|
||||
<div class="showsaside-head">
|
||||
<h2 class="showsaside-h">REFINE</h2>
|
||||
</div>
|
||||
<div class="showsaside-head">
|
||||
<h2 class="showsaside-h">REFINE</h2>
|
||||
</div>
|
||||
|
||||
<div class="showsaside-body">
|
||||
<!-- Designer 多选 -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('designers')">
|
||||
<span>DESIGNERS</span>
|
||||
<span class="showsg-right">
|
||||
<span class="showsg-count" x-text="selectedBrands.length">0</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||
<ul class="showschecks">
|
||||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||||
<li>
|
||||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
|
||||
<span x-text="b.name"></span>
|
||||
<span class="ct" x-text="b.article_count ? b.article_count : ''"></span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Collection type -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('collection')">
|
||||
<span>COLLECTION</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{collectionOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
<div class="showsaside-body">
|
||||
<!-- Designer 多选 -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('designers')">
|
||||
<span>{t('brands')}</span>
|
||||
<span class="showsg-right">
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||
<ul class="showschecks">
|
||||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||||
<li>
|
||||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
|
||||
<span x-text="b.name"></span>
|
||||
<span class="ct" x-text="b.article_count ? b.article_count : ''"></span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Season -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('season')">
|
||||
<span>SEASON</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.season">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{seasonOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Collection type -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('collection')">
|
||||
<span>COLLECTION</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{collectionOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Year -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('year')">
|
||||
<span>YEAR</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.year">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{yearOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Season -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('season')">
|
||||
<span>SEASON</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.season">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{seasonOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
|
||||
</div>
|
||||
<!-- Year -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('year')">
|
||||
<span>YEAR</span>
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.year">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{yearOptions.map((o) => (
|
||||
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<main class="showsmain">
|
||||
<div class="showsgrid" :class="{ 'is-loading': loading }">
|
||||
<div class="showsgrid" :class="{ 'is-loading': loading }">
|
||||
|
||||
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
|
||||
<template x-if="loading && items.length === 0">
|
||||
<template x-for="i in skeletonCount" :key="'skel-'+i">
|
||||
<div class="showscard showscard--skel" aria-hidden="true">
|
||||
<div class="showscard-left">
|
||||
<div class="showscard-img"></div>
|
||||
</div>
|
||||
<div class="showscard-right">
|
||||
<div class="showscard-info">
|
||||
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
|
||||
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
|
||||
<div class="showsline h-[38px] rounded-[2px]"></div>
|
||||
</div>
|
||||
<div class="showscard-strip">
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
|
||||
<template x-if="loading && items.length === 0">
|
||||
<template x-for="i in skeletonCount" :key="'skel-'+i">
|
||||
<div class="showscard showscard--skel" aria-hidden="true">
|
||||
<div class="showscard-left">
|
||||
<div class="showscard-img"></div>
|
||||
</div>
|
||||
<div class="showscard-right">
|
||||
<div class="showscard-info">
|
||||
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
|
||||
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
|
||||
<div class="showsline h-[38px] rounded-[2px]"></div>
|
||||
</div>
|
||||
<div class="showscard-strip">
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
<div class="showscard-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="!loading && items.length === 0">
|
||||
<div class="showsempty">No shows match the current filters</div>
|
||||
</template>
|
||||
<!-- Empty State -->
|
||||
<template x-if="!loading && items.length === 0">
|
||||
<div class="showsempty">No shows match the current filters</div>
|
||||
</template>
|
||||
|
||||
<!-- Card List -->
|
||||
<template x-for="it in items" :key="it.id">
|
||||
<!-- Card List -->
|
||||
<template x-for="it in items" :key="it.id">
|
||||
<a class="showscard" :href="itemHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
|
||||
<div class="showscard-left">
|
||||
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
||||
@ -238,12 +237,12 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="showsloadmore-wrap" x-show="items.length > 0">
|
||||
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
|
||||
</div>
|
||||
<div class="showsloadmore-wrap" x-show="items.length > 0">
|
||||
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
262
src/components/pages/AuthForm.astro
Normal file
262
src/components/pages/AuthForm.astro
Normal file
@ -0,0 +1,262 @@
|
||||
---
|
||||
// 账号表单(登录 / 注册通用),编辑风黑白设计,匹配全站视觉。
|
||||
// - mode: "login" | "register"
|
||||
// - 提交:POST /api/auth/login 或 /api/auth/register,成功后写入本地会话并跳转。
|
||||
// - 后端不可用时也能优雅显示错误(不破坏设计)。
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
interface Props {
|
||||
mode?: "login" | "register"
|
||||
}
|
||||
const { mode = "login" } = Astro.props
|
||||
const { locale } = getI18n(Astro)
|
||||
|
||||
const isLogin = mode === "login"
|
||||
const title = isLogin ? "Sign In" : "Create Account"
|
||||
const kickerRight = isLogin ? "Sign In" : "Create Account"
|
||||
const subtitle = isLogin
|
||||
? "Sign in to your account to access the runway archive."
|
||||
: "Create an account to save shows and build your own archive."
|
||||
const submitLabel = isLogin ? "Sign In" : "Create Account"
|
||||
// 登录/注册成功后回跳目标:优先 ?redirect=,否则回 /en/
|
||||
const redirect = Astro.url.searchParams.get("redirect") || href(locale, ROUTES.home)
|
||||
|
||||
const loginHref = href(locale, ROUTES.login)
|
||||
const registerHref = href(locale, ROUTES.register)
|
||||
---
|
||||
|
||||
<section class="auth-wrap">
|
||||
<div class="auth-kicker">
|
||||
<span>Account</span>
|
||||
<span>{kickerRight}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="auth-title">{title}</h1>
|
||||
<p class="auth-sub">{subtitle}</p>
|
||||
|
||||
<form id="auth-form" class="auth-form" data-mode={mode} data-redirect={redirect} novalidate>
|
||||
{!isLogin && (
|
||||
<label class="auth-field">
|
||||
<span class="auth-label">Username</span>
|
||||
<input
|
||||
class="auth-input"
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
placeholder="yourname"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label class="auth-field">
|
||||
<span class="auth-label">Email</span>
|
||||
<input
|
||||
class="auth-input"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="auth-field">
|
||||
<span class="auth-label">Password</span>
|
||||
<input
|
||||
class="auth-input"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete={isLogin ? "current-password" : "new-password"}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!isLogin && (
|
||||
<label class="auth-field">
|
||||
<span class="auth-label">Confirm Password</span>
|
||||
<input
|
||||
class="auth-input"
|
||||
type="password"
|
||||
name="confirm"
|
||||
autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<p id="auth-error" class="auth-error" hidden></p>
|
||||
|
||||
<button type="submit" class="auth-submit">{submitLabel}</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-foot">
|
||||
{isLogin ? (
|
||||
<>
|
||||
<a class="auth-link" href={registerHref}>Don’t have an account? Sign up →</a>
|
||||
<a class="auth-link muted" href="#">Forgot your password?</a>
|
||||
</>
|
||||
) : (
|
||||
<a class="auth-link" href={loginHref}>Already have an account? Sign in →</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@reference "tailwindcss";
|
||||
|
||||
.auth-wrap {
|
||||
@apply w-full max-w-[460px] mx-auto;
|
||||
padding-top: clamp(40px, 7vw, 90px);
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.auth-kicker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #000;
|
||||
padding-top: 18px;
|
||||
@apply font-mono text-[11px] tracking-[0.25em] uppercase text-[#666];
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
@apply font-serif font-black tracking-[-0.02em] text-black;
|
||||
line-height: 0.9;
|
||||
font-size: clamp(44px, 9vw, 88px);
|
||||
margin: 0.16em 0 0.18em;
|
||||
}
|
||||
|
||||
.auth-sub {
|
||||
@apply font-mono text-[12px] text-[#666];
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 34px;
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
@apply block mb-[22px];
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
display: block;
|
||||
@apply font-mono text-[11px] uppercase tracking-[0.2em] text-black mb-[8px];
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
@apply w-full border border-black bg-white px-[14px] py-[12px] font-sans text-[14px] text-black;
|
||||
outline: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.auth-input::placeholder {
|
||||
@apply text-black/30;
|
||||
}
|
||||
.auth-input:focus {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
@apply font-mono text-[12px] text-black;
|
||||
border: 1px solid #000;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
padding: 10px 12px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
@apply w-full bg-black text-white border border-black px-[18px] py-[14px] text-[12px] uppercase tracking-[0.18em] font-bold cursor-pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.auth-submit:hover {
|
||||
@apply bg-white text-black;
|
||||
}
|
||||
.auth-submit:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.auth-foot {
|
||||
@apply mt-[26px] pt-[22px] border-t border-zinc-200 flex flex-col gap-[10px];
|
||||
@apply font-mono text-[12px] text-[#666];
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
@apply text-black no-underline;
|
||||
}
|
||||
.auth-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.auth-link.muted {
|
||||
@apply text-[#666];
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { saveSession } from "@/lib/api"
|
||||
|
||||
const form = document.getElementById("auth-form") as HTMLFormElement | null
|
||||
if (form) {
|
||||
const errEl = document.getElementById("auth-error") as HTMLParagraphElement | null
|
||||
const btn = form.querySelector('button[type="submit"]') as HTMLButtonElement | null
|
||||
const mode = (form.dataset.mode || "login") as "login" | "register"
|
||||
const redirect = form.dataset.redirect || "/"
|
||||
|
||||
const showErr = (msg: string) => {
|
||||
if (!errEl) return
|
||||
errEl.textContent = msg
|
||||
errEl.hidden = false
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault()
|
||||
if (errEl) errEl.hidden = true
|
||||
const data = Object.fromEntries(new FormData(form).entries()) as Record<string, string>
|
||||
|
||||
if (mode === "register" && data.password !== data.confirm) {
|
||||
showErr("Passwords do not match.")
|
||||
return
|
||||
}
|
||||
|
||||
const label = mode === "login" ? "Sign In" : "Create Account"
|
||||
const orig = btn?.textContent ?? label
|
||||
if (btn) {
|
||||
btn.disabled = true
|
||||
btn.textContent = "…"
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register"
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const json: any = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
showErr(json?.message || json?.error || `Unable to ${label.toLowerCase()}. Please try again.`)
|
||||
return
|
||||
}
|
||||
const token = json?.data?.token ?? json?.token
|
||||
const user = json?.data?.user ?? json?.user
|
||||
if (token && user) {
|
||||
saveSession(token, user)
|
||||
location.href = redirect
|
||||
return
|
||||
}
|
||||
showErr("Unexpected response. Please try again.")
|
||||
} catch {
|
||||
showErr("Network error. Please try again.")
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false
|
||||
btn.textContent = orig
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -18,53 +18,86 @@ type OtherLocale = Exclude<Locale, typeof DEFAULT_LOCALE>;
|
||||
type TranslationEntry = Partial<Record<OtherLocale, string>>;
|
||||
|
||||
export const dict: Record<string, TranslationEntry> = {
|
||||
|
||||
// ── 导航 / Navigation ──
|
||||
'About': { cn: '关于' },
|
||||
'全部的 runway': { cn: '全部的 runway' },
|
||||
'Contact': { cn: '联系' },
|
||||
'Contact Us': { cn: '联系我们' },
|
||||
'Home': { cn: '首页' },
|
||||
'Latest Projects': { cn: '最新项目' },
|
||||
'Login': { cn: '登录' },
|
||||
'Portfolio': { cn: '作品集' },
|
||||
'Runway Archive': { cn: '走秀档案' },
|
||||
'Signup': { cn: '注册' },
|
||||
|
||||
'about': { cn: '关于' },
|
||||
|
||||
'全部的 runway': { cn: '全部的 runway' },
|
||||
|
||||
'contact': { cn: '联系' },
|
||||
|
||||
'contact us': { cn: '联系我们' },
|
||||
|
||||
'home': { cn: '首页' },
|
||||
|
||||
'latest projects': { cn: '最新项目' },
|
||||
|
||||
'login': { cn: '登录' },
|
||||
|
||||
'portfolio': { cn: '作品集' },
|
||||
|
||||
'runway archive': { cn: '走秀档案' },
|
||||
|
||||
'signup': { cn: '注册' },
|
||||
|
||||
// ── 控件 / Controls ──
|
||||
'Back to Index': { cn: '返回首页' },
|
||||
'Back to top': { cn: '回到顶部' },
|
||||
'Close': { cn: '关闭' },
|
||||
'Previous image': { cn: '上一张' },
|
||||
'Next image': { cn: '下一张' },
|
||||
'Previous': { cn: '上一组' },
|
||||
'Next': { cn: '下一组' },
|
||||
|
||||
'back to index': { cn: '返回首页' },
|
||||
|
||||
'back to top': { cn: '回到顶部' },
|
||||
|
||||
'close': { cn: '关闭' },
|
||||
|
||||
'previous image': { cn: '上一张' },
|
||||
|
||||
'next image': { cn: '下一张' },
|
||||
|
||||
'previous': { cn: '上一组' },
|
||||
|
||||
'next': { cn: '下一组' },
|
||||
|
||||
// ── 错误 / Errors ──
|
||||
'No article ID specified': { cn: '未指定文章 ID' },
|
||||
'Failed to load article. Please refresh or try later.': { cn: '加载文章失败,请刷新或稍后再试' },
|
||||
'Click image to zoom · Scroll to view · Esc to close': { cn: '点击图片放大 · 上下滚动查看 · Esc 关闭' },
|
||||
|
||||
'no article id specified': { cn: '未指定文章 ID' },
|
||||
|
||||
'failed to load article. please refresh or try later.': { cn: '加载文章失败,请刷新或稍后再试' },
|
||||
|
||||
'click image to zoom · scroll to view · esc to close': { cn: '点击图片放大 · 上下滚动查看 · Esc 关闭' },
|
||||
|
||||
// ── 页脚 / Footer ──
|
||||
'© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
|
||||
|
||||
'© 2026 portfolio studio. all rights reserved.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
|
||||
|
||||
// ── 页脚工具 / Footer tools ──
|
||||
'Color Studio Tools': { cn: '色彩工作室工具' },
|
||||
'AI Color Trend Generator': { cn: 'AI 色彩趋势生成器' },
|
||||
'Photo Swatch Extractor': { cn: '图片色板提取器' },
|
||||
'Upload': { cn: '上传' },
|
||||
'Reset B&W Theme': { cn: '重置黑白主题' },
|
||||
'Default': { cn: '默认' },
|
||||
|
||||
'color studio tools': { cn: '色彩工作室工具' },
|
||||
|
||||
'ai color trend generator': { cn: 'AI 色彩趋势生成器' },
|
||||
|
||||
'photo swatch extractor': { cn: '图片色板提取器' },
|
||||
|
||||
'upload': { cn: '上传' },
|
||||
|
||||
'reset b&w theme': { cn: '重置黑白主题' },
|
||||
|
||||
'default': { cn: '默认' },
|
||||
|
||||
// ── 页脚简介 / Manifesto ──
|
||||
'Minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. Integrated with 2026 color forecast intelligence and AI palette generator.':
|
||||
|
||||
'minimalist editorial portfolio documenting haute couture shows, backstage fittings, and street culture. integrated with 2026 color forecast intelligence and ai palette generator.':
|
||||
{ cn: '极简编辑风格作品集,记录高级定制秀场、后台试装与街头文化。集成 2026 色彩预测情报与 AI 调色板生成器。' },
|
||||
|
||||
// ── 登录态 / Auth ──
|
||||
'Hi': { cn: '你好' },
|
||||
'Logout': { cn: '退出' },
|
||||
'Language': { cn: '语言' },
|
||||
|
||||
'Runway looks': {cn: '秀场造型'}
|
||||
'hi': { cn: '你好' },
|
||||
|
||||
'logout': { cn: '退出' },
|
||||
|
||||
'language': { cn: '语言' },
|
||||
|
||||
'runway looks': { cn: '秀场造型' }
|
||||
|
||||
};
|
||||
|
||||
/** 把 {name} 形式的占位符替换成参数值。 */
|
||||
@ -81,7 +114,7 @@ function interpolate(text: string, params?: Record<string, string | number>): st
|
||||
export function translate(key: string, locale: Locale, params?: Record<string, string | number>): string {
|
||||
if (locale === DEFAULT_LOCALE) return interpolate(key, params);
|
||||
|
||||
const localized = dict[key]?.[locale as OtherLocale];
|
||||
const localized = dict[key.toLowerCase()]?.[locale as OtherLocale];
|
||||
if (localized) return interpolate(localized, params);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
@ -8,6 +8,9 @@ import { toCodes } from 'astro:i18n';
|
||||
|
||||
import LanguageDetector from '@/components/LanguageDetector.astro'
|
||||
|
||||
const { title } = Astro.props as { title?: string }
|
||||
const siteTitle = title ? `${title} — Portfolio Studio` : "Portfolio Studio"
|
||||
|
||||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||
|
||||
// 经 data-* 属性把已翻译字符串传入打包型 <script>;define:vars 会让脚本变内联 classic,导致 import 报错,故改用 data 属性
|
||||
@ -48,7 +51,7 @@ const headers = {
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{`111`}</title>
|
||||
<title>{siteTitle}</title>
|
||||
</head>
|
||||
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
|
||||
|
||||
|
||||
@ -358,6 +358,13 @@ export async function fetchMe(): Promise<AuthUser | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// 登录/注册成功后写入本地会话(token + user),供 Layout 顶部已登录态与 fetchMe 读取。
|
||||
export function saveSession(token: string, user: AuthUser): void {
|
||||
if (typeof localStorage === "undefined") return
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
}
|
||||
|
||||
// ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) =====================
|
||||
|
||||
function ssgEnv(name: string): string | undefined {
|
||||
|
||||
8
src/pages/en/login.astro
Normal file
8
src/pages/en/login.astro
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import AuthForm from "@/components/pages/AuthForm.astro"
|
||||
---
|
||||
|
||||
<Layout title="Sign In">
|
||||
<AuthForm mode="login" />
|
||||
</Layout>
|
||||
8
src/pages/en/register.astro
Normal file
8
src/pages/en/register.astro
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import AuthForm from "@/components/pages/AuthForm.astro"
|
||||
---
|
||||
|
||||
<Layout title="Create Account">
|
||||
<AuthForm mode="register" />
|
||||
</Layout>
|
||||
Reference in New Issue
Block a user