diff --git a/docs/ui.md b/docs/ui.md
index 2b9f3f5..27b05ae 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -97,7 +97,8 @@
### 3.2 一列,封顶
-内容列封顶 68rem(`--shelf-measure`),宽屏加的是留白而不是更多列。
+首页、书架、影音、我的共用一条内容列:内容宽度封顶 `--page-measure`(`--collection-measure` 80rem 减去书架带内边距,约 1251px),
+两侧留白 `--page-gutter`(手机 16px,侧栏模式 32px),切换标签页时内容边缘不动。宽屏加的是留白而不是更多列。
1600px 窗口排十一个封面、每个书名十二个字,比手机上的三列**更难扫**——
这与「多出来的宽度是为了好读」正好相反。
diff --git a/web/src/app/router/routes.tsx b/web/src/app/router/routes.tsx
index 8cc432c..ecde96e 100644
--- a/web/src/app/router/routes.tsx
+++ b/web/src/app/router/routes.tsx
@@ -63,7 +63,12 @@ export const routes = [{
{ path: 'settings', element: },
{ path: 'book/:bookId', element: },
// 我的 shares MediaLayout so it follows the chosen theme and keeps the channel rail.
- { path: 'me', element: , children: [{ index: true, element: }] },
+ // Its settings rows open /me/:panel (theme, browse, playback, plugins, account),
+ // so back from any of them returns to 我的.
+ { path: 'me', element: , children: [
+ { index: true, element: },
+ { path: ':panel', element: },
+ ] },
{ path: 'media', element: , children: [
{ index: true, element: },
{ path: 'music', element: },
diff --git a/web/src/app/shell.css b/web/src/app/shell.css
index dac69cc..4a5f6b7 100644
--- a/web/src/app/shell.css
+++ b/web/src/app/shell.css
@@ -89,12 +89,42 @@
color: var(--reader-ink);
}
+/* ---- one page column for the four tabs ----
+ * 首页, 书架, 影音 and 我的 share --page-measure / --page-gutter (styles/reader.css), so
+ * switching tabs never moves the content edges. The media-family pages are their own
+ * scrollers, so the column is made with padding (the scrollbar stays at the window
+ * edge); the shelf caps its bands instead, which keeps its sticky toolbar working. */
+@media (min-width: 701px) {
+ :root { --page-gutter: 32px; }
+}
+.media-screen:is(.home-page, .mine-page, .media-catalog-page) {
+ max-width: none;
+ padding-inline: max(var(--page-gutter), calc((100% - var(--page-measure)) / 2));
+}
+/* Phone: the same 20px top as the shelf's header, clear of the notch; the shelf's
+ * 0.9rem band padding is topped up to the shared gutter. */
+@media (max-width: 700px) {
+ .media-screen:is(.home-page, .mine-page) { padding-top: calc(20px + var(--safe-top)); }
+ .shelf { padding-inline: calc(var(--page-gutter) - 0.9rem); }
+}
+@media (min-width: 701px) {
+ .shelf { --shelf-measure: var(--collection-measure); }
+ .shelf-head, .shelf-discovery, .shelf-section { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); }
+ .shelf-head { padding-top: 24px; }
+}
+/* Between the sidebar breakpoint and 64rem the discovery row is display: contents,
+ * so its two children are bands of their own. */
+@media (min-width: 701px) and (max-width: 63.99rem) {
+ .shelf-links, .shelf-search { width: calc(100% - 2 * var(--page-gutter) + 1.8rem); }
+}
+
/* The old fixed channel rail is gone; its top gutter goes with it. */
@media (min-width: 701px) {
/* Only pages whose header scrolls with them; pages with a fixed header keep their 68px. */
body[data-app-nav] .media-screen:is(.media-catalog-page, .mine-page, .home-page) { padding-top: 24px; }
- /* Search and the ⋯ menu used to hang from the rail with a negative offset. */
- body[data-app-nav] .media-heading:has(.media-mobile-title) .media-heading-actions { position: fixed; z-index: 81; top: 12px; right: 32px; }
+ /* Search and the ⋯ menu used to hang from the rail with a negative offset. They end
+ * where the page column ends, not at the window edge. */
+ body[data-app-nav] .media-heading:has(.media-mobile-title) .media-heading-actions { position: fixed; z-index: 81; top: 12px; right: max(var(--page-gutter), calc((100vw - var(--app-nav-width) - var(--page-measure)) / 2)); }
}
/* Fixed page headers were placed to clear the old rail's brand; they now clear the sidebar. */
diff --git a/web/src/features/account/pages/MinePage.tsx b/web/src/features/account/pages/MinePage.tsx
index 701d2d8..535f288 100644
--- a/web/src/features/account/pages/MinePage.tsx
+++ b/web/src/features/account/pages/MinePage.tsx
@@ -1,14 +1,19 @@
import { Link } from 'react-router-dom';
-import { ChevronRight, Film, LibraryBig, LogOut, Palette, PlayCircle, Rss, UserRound, type LucideIcon } from 'lucide-react';
-import type { ReactNode } from 'react';
+import { useState, type ReactNode } from 'react';
+import { ChevronRight, Film, LayoutGrid, LibraryBig, LogOut, Palette, PlayCircle, Puzzle, RefreshCw, Rss, Settings, SlidersHorizontal, UserRound, type LucideIcon } from 'lucide-react';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
+import { useSettingsStore } from '../../../shared/stores/settings.store.ts';
import { themeSummary } from '../../../shared/theme/theme.ts';
import { useThemeState } from '../../../shared/theme/use-theme.ts';
+import { readMediaPreferences } from '../../media/services/preferences.ts';
+import { DENSITY_LABELS, ShelfSettingsPanel } from '../../../ui/shelf-settings.tsx';
+import { OpdsAccess } from '../../../ui/opds-access.tsx';
+import { SystemSettings } from '../../../ui/system-settings.tsx';
import '../../home/styles/home.css';
import '../styles/mine.css';
-function Row({ icon: Icon, label, value, to, onClick, danger = false }: { icon: LucideIcon; label: string; value?: string; to?: string; onClick?: () => void; danger?: boolean }) {
+function Row({ icon: Icon, label, value, to, state, onClick, danger = false }: { icon: LucideIcon; label: string; value?: string; to?: string; state?: unknown; onClick?: () => void; danger?: boolean }) {
const body = <>
{label}
@@ -17,7 +22,7 @@ function Row({ icon: Icon, label, value, to, onClick, danger = false }: { icon:
>;
const className = danger ? 'mine-row is-danger' : 'mine-row';
return to
- ? {body}
+ ? {body}
: ;
}
@@ -30,37 +35,58 @@ function serverHost(baseUrl: string): string {
}
/**
- * 我的: the one place for who you are, what is connected, and the settings that
- * already exist elsewhere. It owns no settings of its own: every row links to the
- * page that does, so there is still exactly one place to change each thing.
+ * 我的: the settings hub. Every setting in the app is reachable from here, grouped by
+ * what it is about rather than by which page happens to own it:
+ * - 偏好: how things look and behave for *you* (theme, the shelf, media browsing, playback);
+ * - 管理: what an administrator changes for everyone (users, services, media libraries);
+ * - 账号: who you are on this server.
+ * Each row opens the one place that setting lives; the shelf sheet and 影音设置 are
+ * shortcuts to subsets of this list, not separate settings.
*/
export function MinePage() {
const runtime = useRuntime();
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
+ const settings = useSettingsStore(state => state.settings);
const admin = user?.role === 'admin';
- const themeName = themeSummary(useThemeState(runtime.mediaApi.preferenceScope()).preference);
+ const scope = runtime.mediaApi.preferenceScope();
+ const themeName = themeSummary(useThemeState(scope).preference);
+ const mediaDensity = readMediaPreferences(scope).density === 'compact' ? '紧凑' : '宽松';
+ const [sheet, setSheet] = useState<'shelf' | 'opds' | 'system' | null>(null);
const name = user?.displayName || user?.username || '';
- return
+ {/* Outside the media screen, so its sheet styles are the shelf's own, not media's. */}
+ { void runtime.updateSettings(patch); }} onClose={() => setSheet(null)} onOpenExternalReader={() => setSheet('opds')} />
+ {sheet === 'opds' && { void runtime.api.signOut(); }} onClose={() => setSheet('shelf')} />}
+ {sheet === 'system' && setSheet(null)} />}
+ >;
}
diff --git a/web/src/features/account/styles/mine.css b/web/src/features/account/styles/mine.css
index ae963d7..506014a 100644
--- a/web/src/features/account/styles/mine.css
+++ b/web/src/features/account/styles/mine.css
@@ -1,5 +1,10 @@
/* 我的: reuses the media aliases of the theme tokens, so it follows the chosen theme. */
-.mine-page { max-width: 640px; }
+/* The page column is the shared one (app/shell.css). On a wide window the groups sit
+ * two to a row instead of stretching each row across the whole column. */
+@media (min-width: 64rem) {
+ .mine-page { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; align-content: start; align-items: start; }
+ .mine-page > :not(.mine-group) { grid-column: 1 / -1; }
+}
.mine-page .media-back-button { color: inherit; text-decoration: none; }
.mine-profile {
diff --git a/web/src/features/home/styles/home.css b/web/src/features/home/styles/home.css
index 3c138d9..4111d4c 100644
--- a/web/src/features/home/styles/home.css
+++ b/web/src/features/home/styles/home.css
@@ -1,6 +1,4 @@
/* 首页: continue where you left off. Uses the shared theme tokens. */
-.home-page { max-width: 1000px; }
-.media-screen.home-page { padding-top: 20px; }
.home-header h1 { margin: 6px 0 0; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 28px; line-height: 1.2; }
.home-header p { margin: 4px 0 0; font-size: 14px; color: var(--muted); }
diff --git a/web/src/features/media/components/settings.tsx b/web/src/features/media/components/settings.tsx
index 8d92579..8e3dc38 100644
--- a/web/src/features/media/components/settings.tsx
+++ b/web/src/features/media/components/settings.tsx
@@ -2,19 +2,21 @@ import {FloatingConfirm} from '../../../ui/floating-confirm.tsx';
import {MediaSelect} from './select.tsx';
import {PlaybackSettings} from './playback-settings.tsx';
import {MediaThemeSettings} from './theme-settings.tsx';
-import {themeSummary} from '../../../shared/theme/theme.ts';
-import {useThemeState} from '../../../shared/theme/use-theme.ts';
import type {AppSettings} from '../../../store/settings.ts';
export type SettingsPanel = 'home'|'theme'|'browse'|'playback'|'plugins'|'account';
import { useState } from 'react';
-import {ChevronLeft,ChevronRight,Clock3,Heart,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Palette} from 'lucide-react';
+import {ChevronLeft,ChevronRight,Clock3,Headphones,FolderOpen,SlidersHorizontal,RefreshCw,UserRound,Puzzle} from 'lucide-react';
import type {MediaPlayer} from '../services/player.ts';
import type {MediaApi,MediaAccount} from '../api/media-api.ts';
import {MediaProviderSettings} from './provider-settings.tsx';
import {defaultMediaPreferences,saveMediaPreferences,type MediaPreferences} from '../services/preferences.ts';
-export function MediaSettings({scope,preferences,player,admin,api: _api,account,onSaved,onBack,onPersonal,onManage,onTasks,onPlayback,panel:routePanel,onPanelChange,onReaderSettings}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onPersonal:(view:'favorites'|'history'|'queue')=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string;onReaderSettings?:(patch:Partial)=>void}){
- const {preference:themePreference}=useThemeState(scope);
+/**
+ * 影音设置: only what is about media. Theme, account and the rest of the app-wide
+ * settings live in 我的; their panels are still rendered here (我的 links to them),
+ * but the media home panel does not list them a second time.
+ */
+export function MediaSettings({scope,preferences,player,admin,api: _api,account,onSaved,onBack,onManage,onTasks,onPlayback,onGeneral,panel:routePanel,onPanelChange,onReaderSettings,backLabel='返回影音设置'}:{scope:string;preferences:MediaPreferences;player:MediaPlayer;admin:boolean;api?:MediaApi;account?:MediaAccount|null;onSaved:(value:MediaPreferences)=>void;onBack:()=>void;onManage:()=>void;onTasks?:()=>void;onPlayback?:()=>void;onGeneral?:()=>void;panel?:SettingsPanel;onPanelChange?:(panel:SettingsPanel)=>void;channelLabel?:string;onReaderSettings?:(patch:Partial)=>void;backLabel?:string}){
const [localPanel,setLocalPanel]=useState('home');
const panel=routePanel??localPanel;
function setPanel(value:SettingsPanel){if(onPanelChange)onPanelChange(value);else setLocalPanel(value);}
@@ -25,23 +27,18 @@ export function MediaSettings({scope,preferences,player,admin,api: _api,account,
function save(){setError('');setSaved(false);try{const value=saveMediaPreferences(scope,draft);onSaved(value);setSaved(true);}catch(error){setError(error instanceof Error?error.message:'保存失败');}}
function change(patch:Partial){setDraft({...draft,...patch});setSaved(false);}
function openBrowse(){if(!dirty)setDraft({...preferences});setError('');setSaved(false);setPanel('browse');}
- const title=panel==='theme'?'主题外观':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='plugins'?'来源与刮削':panel==='account'?'账号':'影音设置';
+ const title=panel==='theme'?'外观与主题':panel==='browse'?'浏览偏好':panel==='playback'?'播放设置':panel==='plugins'?'来源与刮削':panel==='account'?'账号与连接':'影音设置';
const link=(Icon:typeof Clock3,label:string,description:string,onClick:()=>void)=>;
return
-