fix(ui): mobile media nav, product chrome, empty CTAs, density label, a11y

This commit is contained in:
noelorin 2026-09-26 01:11:13 +08:00
parent a7873891f2
commit 11a18a367c
13 changed files with 139 additions and 24 deletions

View File

@ -15,12 +15,46 @@ export function AppShell(props: {
children: ReactNode; children: ReactNode;
}) { }) {
const base = `/${props.locale}`; const base = `/${props.locale}`;
const product = props.labels["meta.product"] ?? props.labels["meta.brand"] ?? "弹弹 Web";
return ( return (
<div className="flex min-h-screen bg-background text-foreground"> <div className="flex min-h-screen flex-col bg-background text-foreground md:flex-row">
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4"> {/* 窄屏:顶栏品牌 + 横向导航(md 以下侧栏隐藏时的唯一入口) */}
<div className="mb-6 px-2 text-sm font-semibold tracking-wide"> <div className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur md:hidden">
{props.labels["meta.brand"] ?? "弹弹 Web"} <div className="flex items-center gap-3 px-3 pt-3">
<a href={`${base}/library`} className="text-sm font-semibold tracking-wide">
{product}
</a>
</div> </div>
<nav
className="flex gap-1 overflow-x-auto px-2 pb-2 pt-2"
aria-label="media-nav-mobile"
>
{NAV.map((item) => (
<a
key={item.id}
href={`${base}/${item.href}`}
className={cn(
"shrink-0 rounded-full px-3 py-1.5 text-sm transition-colors",
props.active === item.id
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
{props.labels[item.hrefKey] ?? item.href}
</a>
))}
</nav>
</div>
{/* 宽屏:侧栏 */}
<aside className="hidden w-56 shrink-0 border-r border-border bg-muted/40 md:flex md:flex-col md:p-4">
<a
href={`${base}/library`}
className="mb-6 px-2 text-sm font-semibold tracking-wide"
>
{product}
</a>
<nav className="flex flex-col gap-1" aria-label="sidebar"> <nav className="flex flex-col gap-1" aria-label="sidebar">
{NAV.map((item) => ( {NAV.map((item) => (
<a <a
@ -38,6 +72,7 @@ export function AppShell(props: {
))} ))}
</nav> </nav>
</aside> </aside>
<main className="flex min-w-0 flex-1 flex-col p-4 md:p-6">{props.children}</main> <main className="flex min-w-0 flex-1 flex-col p-4 md:p-6">{props.children}</main>
</div> </div>
); );

View File

@ -173,8 +173,16 @@ function BrowseInner(props: { locale: Locale }) {
</li> </li>
)} )}
{!mountId && ( {!mountId && (
<li className="p-4 text-sm text-muted-foreground"> <li className="flex flex-col items-start gap-3 p-4 text-sm text-muted-foreground">
{t(locale, "browse.empty")} <span>{t(locale, "browse.empty")}</span>
<Button
size="sm"
onClick={() => {
window.location.href = `/${locale}/mounts`;
}}
>
{t(locale, "browse.addMount")}
</Button>
</li> </li>
)} )}
</ul> </ul>

View File

@ -1,6 +1,6 @@
import type { Locale } from "@app/i18n"; import type { Locale } from "@app/i18n";
import { t } from "@app/i18n"; import { t } from "@app/i18n";
import { Button } from "@app/ui"; import { Button, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
@ -48,7 +48,14 @@ function Inner(props: { locale: Locale }) {
const onSave = () => { const onSave = () => {
const next = { enabled, opacity, density }; const next = { enabled, opacity, density };
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next)); localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
save.mutate(next); save.mutate(next, {
onSuccess: () => {
toast.success(t(locale, "danmaku.saved"));
},
onError: () => {
toast.error(t(locale, "danmaku.save"));
},
});
}; };
return ( return (
@ -75,7 +82,9 @@ function Inner(props: { locale: Locale }) {
/> />
</label> </label>
<label className="block text-sm"> <label className="block text-sm">
{t(locale, "danmaku.densityValue", { value: density })} {t(locale, "danmaku.densityValue", {
value: (density * 100).toFixed(0),
})}
<input <input
type="range" type="range"
min={0.1} min={0.1}
@ -86,7 +95,9 @@ function Inner(props: { locale: Locale }) {
className="w-full" className="w-full"
/> />
</label> </label>
<Button onClick={onSave}>{t(locale, "danmaku.save")}</Button> <Button type="button" onClick={onSave}>
{t(locale, "danmaku.save")}
</Button>
{save.isSuccess && ( {save.isSuccess && (
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p> <p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
)} )}

View File

@ -108,9 +108,28 @@ function LibraryInner(props: { locale: Locale }) {
</div> </div>
))} ))}
{progressList.length === 0 && !list.isPending && ( {progressList.length === 0 && !list.isPending && (
<p className="col-span-full text-sm text-muted-foreground"> <div className="col-span-full flex flex-col items-start gap-3 rounded-lg border border-border bg-card p-6">
<p className="text-sm text-muted-foreground">
{t(locale, "library.empty")} {t(locale, "library.empty")}
</p> </p>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
window.location.href = `/${locale}/browse`;
}}
>
{t(locale, "library.ctaBrowse")}
</Button>
<Button
variant="outline"
onClick={() => {
window.location.href = `/${locale}/mounts`;
}}
>
{t(locale, "library.ctaMounts")}
</Button>
</div>
</div>
)} )}
</div> </div>
{hasMore && ( {hasMore && (

View File

@ -46,29 +46,34 @@ function MountsInner(props: { locale: Locale }) {
}} }}
> >
<Input <Input
aria-label={t(locale, "mounts.namePlaceholder")}
placeholder={t(locale, "mounts.namePlaceholder")} placeholder={t(locale, "mounts.namePlaceholder")}
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
required required
/> />
<Input <Input
aria-label={t(locale, "mounts.baseUrlPlaceholder")}
placeholder={t(locale, "mounts.baseUrlPlaceholder")} placeholder={t(locale, "mounts.baseUrlPlaceholder")}
value={form.baseUrl} value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })} onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
required required
/> />
<Input <Input
aria-label={t(locale, "mounts.usernamePlaceholder")}
placeholder={t(locale, "mounts.usernamePlaceholder")} placeholder={t(locale, "mounts.usernamePlaceholder")}
value={form.username} value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })} onChange={(e) => setForm({ ...form, username: e.target.value })}
/> />
<Input <Input
type="password" type="password"
aria-label={t(locale, "mounts.passwordPlaceholder")}
placeholder={t(locale, "mounts.passwordPlaceholder")} placeholder={t(locale, "mounts.passwordPlaceholder")}
value={form.password} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} onChange={(e) => setForm({ ...form, password: e.target.value })}
/> />
<Input <Input
aria-label={t(locale, "mounts.rootPathPlaceholder")}
placeholder={t(locale, "mounts.rootPathPlaceholder")} placeholder={t(locale, "mounts.rootPathPlaceholder")}
value={form.rootPath} value={form.rootPath}
onChange={(e) => setForm({ ...form, rootPath: e.target.value })} onChange={(e) => setForm({ ...form, rootPath: e.target.value })}

View File

@ -22,6 +22,8 @@ export interface Props {
seo?: PageSeoInput; seo?: PageSeoInput;
/** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */ /** 媒体页只保留 AppShell 侧栏时隐藏模板 side-rail(含左让位) */
hideSideRail?: boolean; hideSideRail?: boolean;
/** 媒体页隐藏模板页脚 */
hideFooter?: boolean;
} }
const { const {
@ -33,10 +35,12 @@ const {
switchLabel, switchLabel,
seo, seo,
hideSideRail = false, hideSideRail = false,
hideFooter = false,
} = Astro.props; } = Astro.props;
const brandName = brand ?? t(locale, "meta.brand"); const brandName = brand ?? t(locale, "meta.brand");
const nav = navProp ?? []; const nav = navProp ?? [];
const isProductBrand = Boolean(brand) && brand !== t(locale, "meta.brand");
const siteSeo = getSiteSeo(locale); const siteSeo = getSiteSeo(locale);
const pageSeoInput: PageSeoInput = { const pageSeoInput: PageSeoInput = {
@ -50,8 +54,12 @@ const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput));
const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title; const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title;
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en"; const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
const brandHref = homeHref; const brandHref = isProductBrand ? (locale === "zh-CN" ? "/zh-CN/library" : "/en/library") : homeHref;
const brandInitial = locale === "zh-CN" ? "模" : "A"; const brandInitial = isProductBrand
? brandName.charAt(0)
: locale === "zh-CN"
? "模"
: "A";
--- ---
<!doctype html> <!doctype html>
@ -210,13 +218,17 @@ const brandInitial = locale === "zh-CN" ? "模" : "A";
<slot /> <slot />
</main> </main>
{!hideFooter && (
<footer class="site-footer"> <footer class="site-footer">
<div class="u-dotline" aria-hidden="true"></div> <div class="u-dotline" aria-hidden="true"></div>
<div class="site-footer__inner"> <div class="site-footer__inner">
<p class="u-mono">{t(locale, "meta.brand")}</p> <p class="u-mono">{isProductBrand ? brandName : t(locale, "meta.brand")}</p>
<p class="u-label">{t(locale, "home.footer")}</p> <p class="u-label">
{isProductBrand ? t(locale, "meta.productTagline") : t(locale, "home.footer")}
</p>
</div> </div>
</footer> </footer>
)}
</div> </div>
</body> </body>
</html> </html>

View File

@ -13,6 +13,7 @@ const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = { const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"), "meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"), "nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"), "nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"), "nav.mounts": t(locale, "nav.mounts"),
@ -25,6 +26,8 @@ const labels: Record<string, string> = {
locale={locale} locale={locale}
nav={[]} nav={[]}
hideSideRail hideSideRail
hideFooter
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)} switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)} switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{

View File

@ -13,6 +13,7 @@ const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = { const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"), "meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"), "nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"), "nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"), "nav.mounts": t(locale, "nav.mounts"),
@ -25,6 +26,8 @@ const labels: Record<string, string> = {
locale={locale} locale={locale}
nav={[]} nav={[]}
hideSideRail hideSideRail
hideFooter
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)} switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)} switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{

View File

@ -13,6 +13,7 @@ const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = { const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"), "meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"), "nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"), "nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"), "nav.mounts": t(locale, "nav.mounts"),
@ -25,6 +26,8 @@ const labels: Record<string, string> = {
locale={locale} locale={locale}
nav={[]} nav={[]}
hideSideRail hideSideRail
hideFooter
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)} switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)} switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{

View File

@ -13,6 +13,7 @@ const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = { const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"), "meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"), "nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"), "nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"), "nav.mounts": t(locale, "nav.mounts"),
@ -25,6 +26,8 @@ const labels: Record<string, string> = {
locale={locale} locale={locale}
nav={[]} nav={[]}
hideSideRail hideSideRail
hideFooter
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)} switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)} switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{

View File

@ -14,6 +14,7 @@ const { locale, mediaItemId } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN"; const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = { const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"), "meta.brand": t(locale, "meta.brand"),
"meta.product": t(locale, "meta.product"),
"nav.library": t(locale, "nav.library"), "nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"), "nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"), "nav.mounts": t(locale, "nav.mounts"),
@ -26,6 +27,8 @@ const labels: Record<string, string> = {
locale={locale} locale={locale}
nav={[]} nav={[]}
hideSideRail hideSideRail
hideFooter
brand={t(locale, "meta.product")}
switchHref={alternatePath(Astro.url.pathname, other)} switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)} switchLabel={t(other, `locale.${other}` as never)}
seo={{ seo={{

View File

@ -3,6 +3,8 @@ import type { MessageKey } from "./zh-CN.js";
/** 与 zh-CN 同构;类型强制 key 对齐。 */ /** 与 zh-CN 同构;类型强制 key 对齐。 */
export const en: Record<MessageKey, string> = { export const en: Record<MessageKey, string> = {
"meta.brand": "App Template", "meta.brand": "App Template",
"meta.product": "DanDan Web",
"meta.productTagline": "Local files · online danmaku",
"meta.tagline": "Astro · tRPC · Drizzle scaffold", "meta.tagline": "Astro · tRPC · Drizzle scaffold",
"nav.home": "Home", "nav.home": "Home",
@ -48,6 +50,8 @@ export const en: Record<MessageKey, string> = {
"library.match": "Match", "library.match": "Match",
"library.empty": "No media yet. Scan a mount in the file browser first.", "library.empty": "No media yet. Scan a mount in the file browser first.",
"library.loadMore": "Load more", "library.loadMore": "Load more",
"library.ctaBrowse": "Open file browser",
"library.ctaMounts": "Add mount",
"library.matchTitle": "Manual Bangumi match", "library.matchTitle": "Manual Bangumi match",
"library.searchWork": "Search by title", "library.searchWork": "Search by title",
"library.bind": "Bind", "library.bind": "Bind",
@ -61,6 +65,7 @@ export const en: Record<MessageKey, string> = {
"browse.root": "Root", "browse.root": "Root",
"browse.up": "Up", "browse.up": "Up",
"browse.empty": "Empty directory or no mount selected", "browse.empty": "Empty directory or no mount selected",
"browse.addMount": "Add mount",
"browse.loading": "Loading…", "browse.loading": "Loading…",
"browse.loadError": "Failed to read directory", "browse.loadError": "Failed to read directory",
"browse.playError": "Failed to add to library", "browse.playError": "Failed to add to library",
@ -104,7 +109,7 @@ export const en: Record<MessageKey, string> = {
"danmaku.density": "Density", "danmaku.density": "Density",
"danmaku.enabled": "Enable danmaku", "danmaku.enabled": "Enable danmaku",
"danmaku.opacityValue": "Opacity {value}%", "danmaku.opacityValue": "Opacity {value}%",
"danmaku.densityValue": "Density {value}x", "danmaku.densityValue": "Density {value}%",
"danmaku.save": "Save", "danmaku.save": "Save",
"danmaku.saved": "Saved (local session)", "danmaku.saved": "Saved (local session)",
"danmaku.localImportHint": "danmaku.localImportHint":

View File

@ -5,6 +5,8 @@
*/ */
export const zhCN = { export const zhCN = {
"meta.brand": "应用模板", "meta.brand": "应用模板",
"meta.product": "弹弹 Web",
"meta.productTagline": "本地片源 · 在线弹幕",
"meta.tagline": "Astro · tRPC · Drizzle 脚手架", "meta.tagline": "Astro · tRPC · Drizzle 脚手架",
"nav.home": "首页", "nav.home": "首页",
@ -49,6 +51,8 @@ export const zhCN = {
"library.match": "匹配", "library.match": "匹配",
"library.empty": "暂无媒体,请先在文件浏览中扫描挂载。", "library.empty": "暂无媒体,请先在文件浏览中扫描挂载。",
"library.loadMore": "加载更多", "library.loadMore": "加载更多",
"library.ctaBrowse": "打开文件浏览",
"library.ctaMounts": "添加挂载",
"library.matchTitle": "手动匹配 Bangumi", "library.matchTitle": "手动匹配 Bangumi",
"library.searchWork": "搜索作品名", "library.searchWork": "搜索作品名",
"library.bind": "绑定", "library.bind": "绑定",
@ -62,6 +66,7 @@ export const zhCN = {
"browse.root": "根目录", "browse.root": "根目录",
"browse.up": "上一级", "browse.up": "上一级",
"browse.empty": "空目录或未选择挂载", "browse.empty": "空目录或未选择挂载",
"browse.addMount": "添加挂载",
"browse.loading": "加载中…", "browse.loading": "加载中…",
"browse.loadError": "目录读取失败", "browse.loadError": "目录读取失败",
"browse.playError": "入库失败,请重试", "browse.playError": "入库失败,请重试",
@ -105,7 +110,7 @@ export const zhCN = {
"danmaku.density": "密度", "danmaku.density": "密度",
"danmaku.enabled": "启用弹幕", "danmaku.enabled": "启用弹幕",
"danmaku.opacityValue": "不透明度 {value}%", "danmaku.opacityValue": "不透明度 {value}%",
"danmaku.densityValue": "密度 {value}x", "danmaku.densityValue": "密度 {value}%",
"danmaku.save": "保存", "danmaku.save": "保存",
"danmaku.saved": "已保存(本地会话)", "danmaku.saved": "已保存(本地会话)",
"danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。", "danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。",