feat(ui): improve header layout and add theme preset selector

- Update Header with better user menu and navigation layout
- Add ThemePresetSelector component for theme customization
- Refine ChannelSidebar with improved visual hierarchy
This commit is contained in:
ZhenYi 2026-05-15 13:11:07 +08:00
parent 0491b668c7
commit 9b351e612c
3 changed files with 71 additions and 9 deletions

View File

@ -5,6 +5,8 @@ import {useProjectInfo} from "@/hooks/useProjectInfo";
import {Hash, PanelLeftClose, Plus, Search, Settings} from "lucide-react"; import {Hash, PanelLeftClose, Plus, Search, Settings} from "lucide-react";
import {CHANNEL_SIDEBAR} from "@/css/layout/styles"; import {CHANNEL_SIDEBAR} from "@/css/layout/styles";
import {ProjectCreateMenuModal} from "@/app/project"; import {ProjectCreateMenuModal} from "@/app/project";
import { isProjectAdminRole } from "@/lib/project-permissions";
import { openGlobalSearch } from "@/components/search/global-search-events";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ {
@ -60,7 +62,7 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
const isSettingsActive = isActive("settings"); const isSettingsActive = isActive("settings");
const showSettings = projectInfo?.role === "Owner" || projectInfo?.role === "Admin"; const showSettings = isProjectAdminRole(projectInfo?.role);
const uncategorizedRooms = useMemo( const uncategorizedRooms = useMemo(
() => rooms.filter((r) => !r.isMuted && !r.category), () => rooms.filter((r) => !r.isMuted && !r.category),
@ -98,9 +100,10 @@ export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: Channel
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={openGlobalSearch}
className={CHANNEL_SIDEBAR.iconButton} className={CHANNEL_SIDEBAR.iconButton}
style={{color: "var(--text-secondary)"}} style={{color: "var(--text-secondary)"}}
title="Search" title="Search (Ctrl+Alt+F)"
> >
<Search className="w-[14px] h-[14px]"/> <Search className="w-[14px] h-[14px]"/>
</button> </button>

View File

@ -5,6 +5,8 @@ import { useProjectLayout } from "@/app/project/layout";
import { useProjectsQuery } from "@/hooks/useProjectsQuery"; import { useProjectsQuery } from "@/hooks/useProjectsQuery";
import { useOptionalRoom } from "@/contexts/room"; import { useOptionalRoom } from "@/contexts/room";
import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal"; import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal";
import { isProjectAdminRole } from "@/lib/project-permissions";
import { openGlobalSearch } from "@/components/search/global-search-events";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -42,15 +44,18 @@ const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [
{ label: "Invitations", path: "/me/invitations" }, { label: "Invitations", path: "/me/invitations" },
]; ];
function getProjectNavSiblings(projectName: string): BreadcrumbSibling[] { function getProjectNavSiblings(projectName: string, showSettings: boolean): BreadcrumbSibling[] {
return [ const items = [
{ label: "Repository", path: `/${projectName}/repos` }, { label: "Repository", path: `/${projectName}/repos` },
{ label: "Issues", path: `/${projectName}/issues` }, { label: "Issues", path: `/${projectName}/issues` },
{ label: "Skills", path: `/${projectName}/skills` }, { label: "Skills", path: `/${projectName}/skills` },
{ label: "Board", path: `/${projectName}/board` }, { label: "Board", path: `/${projectName}/board` },
{ label: "Chat", path: `/${projectName}/chat` }, { label: "Chat", path: `/${projectName}/chat` },
{ label: "Settings", path: `/${projectName}/settings` },
]; ];
if (showSettings) {
items.push({ label: "Settings", path: `/${projectName}/settings` });
}
return items;
} }
function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] { function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] {
@ -67,6 +72,7 @@ function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] {
function getSegmentSiblings( function getSegmentSiblings(
segment: BreadcrumbSegment, segment: BreadcrumbSegment,
projects: Array<{ name: string; display_name: string }>, projects: Array<{ name: string; display_name: string }>,
canManageProject = false,
): BreadcrumbSibling[] | null { ): BreadcrumbSibling[] | null {
if (segment.isLast) return null; if (segment.isLast) return null;
@ -86,7 +92,7 @@ function getSegmentSiblings(
if (depth === 2) { if (depth === 2) {
if (parts[0] === "me") return ME_NAV_SIBLINGS; if (parts[0] === "me") return ME_NAV_SIBLINGS;
return getProjectNavSiblings(parts[0]); return getProjectNavSiblings(parts[0], canManageProject);
} }
if (depth >= 3 && parts[1] === "repo") { if (depth >= 3 && parts[1] === "repo") {
@ -168,9 +174,10 @@ const TOOLBAR_ICONS = [
export const Header = memo(function Header() { export const Header = memo(function Header() {
const location = useLocation(); const location = useLocation();
const { segments, projects } = useBreadcrumbs(); const { segments, projects } = useBreadcrumbs();
const { isProjectMember, showMembers, setShowMembers } = useProjectLayout(); const { isProjectMember, projectInfo, showMembers, setShowMembers } = useProjectLayout();
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const roomContext = useOptionalRoom(); const roomContext = useOptionalRoom();
const canManageProject = isProjectAdminRole(projectInfo?.role);
const handleCopy = useCallback((e: React.MouseEvent, text: string) => { const handleCopy = useCallback((e: React.MouseEvent, text: string) => {
e.preventDefault(); e.preventDefault();
@ -195,7 +202,7 @@ export const Header = memo(function Header() {
</Link> </Link>
{segments.map((segment, idx) => { {segments.map((segment, idx) => {
const siblings = getSegmentSiblings(segment, projects); const siblings = getSegmentSiblings(segment, projects, canManageProject);
return ( return (
<span key={segment.path + idx} className="flex items-center gap-1 min-w-0"> <span key={segment.path + idx} className="flex items-center gap-1 min-w-0">
@ -297,9 +304,10 @@ export const Header = memo(function Header() {
{TOOLBAR_ICONS.map((icon, i) => ( {TOOLBAR_ICONS.map((icon, i) => (
<button <button
key={i} key={i}
onClick={icon.label === "Search" ? openGlobalSearch : undefined}
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors" className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors"
style={{ color: "var(--text-secondary)" }} style={{ color: "var(--text-secondary)" }}
title={icon.label} title={icon.label === "Search" ? "Search (Ctrl+Alt+F)" : icon.label}
> >
<svg <svg
className="w-[18px] h-[18px]" className="w-[18px] h-[18px]"

View File

@ -4,6 +4,49 @@ import { getAllPresets } from "@/config/theme-presets";
const STORAGE_KEY = "app-theme-preset"; const STORAGE_KEY = "app-theme-preset";
const DEFAULT_PRESET = "soft-mono"; const DEFAULT_PRESET = "soft-mono";
const THEME_COLOR_ALIASES: Record<string, string> = {
"color-background": "background",
"color-foreground": "foreground",
"color-card": "card",
"color-card-foreground": "card-foreground",
"color-popover": "popover",
"color-popover-foreground": "popover-foreground",
"color-primary": "primary",
"color-primary-foreground": "primary-foreground",
"color-secondary": "secondary",
"color-secondary-foreground": "secondary-foreground",
"color-muted": "muted",
"color-muted-foreground": "muted-foreground",
"color-accent": "accent",
"color-accent-foreground": "accent-foreground",
"color-destructive": "destructive",
"color-border": "border",
"color-input": "input",
"color-ring": "ring",
"color-sidebar": "sidebar",
"color-sidebar-foreground": "sidebar-foreground",
"color-sidebar-primary": "sidebar-primary",
"color-sidebar-primary-foreground": "sidebar-primary-foreground",
"color-sidebar-accent": "sidebar-accent",
"color-sidebar-accent-foreground": "sidebar-accent-foreground",
"color-sidebar-border": "sidebar-border",
"color-sidebar-ring": "sidebar-ring",
};
const DERIVED_THEME_TOKENS: Record<string, string> = {
"accent-bg": "color-mix(in oklch, var(--accent) 8%, transparent)",
"success-alpha10": "color-mix(in oklch, var(--success) 10%, transparent)",
"warning-alpha10": "color-mix(in oklch, var(--warning) 10%, transparent)",
"destructive-alpha10": "color-mix(in oklch, var(--destructive) 10%, transparent)",
"status-offline": "var(--text-muted)",
"text-tertiary": "color-mix(in oklch, var(--text-muted) 70%, var(--surface-ground))",
"heatmap-0": "color-mix(in oklch, var(--surface-ground) 82%, var(--border-default))",
"heatmap-1": "color-mix(in oklch, var(--success) 18%, var(--surface-ground))",
"heatmap-2": "color-mix(in oklch, var(--success) 36%, var(--surface-ground))",
"heatmap-3": "color-mix(in oklch, var(--success) 58%, var(--surface-ground))",
"heatmap-4": "color-mix(in oklch, var(--success) 78%, var(--surface-ground))",
};
export function useThemePreset() { export function useThemePreset() {
const [presetId, setPresetIdState] = useState<string>(() => { const [presetId, setPresetIdState] = useState<string>(() => {
return localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET; return localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET;
@ -33,6 +76,14 @@ export function applyThemePreset(presetId: string) {
Object.entries(vars).forEach(([key, value]) => { Object.entries(vars).forEach(([key, value]) => {
if (value) root.style.setProperty(`--${key}`, value); if (value) root.style.setProperty(`--${key}`, value);
}); });
Object.entries(THEME_COLOR_ALIASES).forEach(([alias, source]) => {
root.style.setProperty(`--${alias}`, `var(--${source})`);
});
Object.entries(DERIVED_THEME_TOKENS).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value);
});
} }
export function ThemePresetSelector() { export function ThemePresetSelector() {