Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 83 additions & 205 deletions openless-all/app/src/components/FloatingShell.tsx

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions openless-all/app/src/components/SavedToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SavedToast.tsx — 控制台卡右上角的"正在保存 / 已保存 / 失败"小 pill。
// 父级 scroll wrapper(FloatingShell main 区)已设 position:relative,
// 此 pill 用 absolute 锚到右上角,避免在页面顶部撑成一条难看的长横幅。

import type { CSSProperties } from 'react';

export type SaveToastState = 'idle' | 'saving' | 'saved' | 'failed';

interface SavedToastProps {
saveState: SaveToastState;
message: string;
/** 覆盖默认 top:16 right:16 偏移,例如 SettingsModal 里要避开 28×28 的关闭按钮。 */
offsetStyle?: Pick<CSSProperties, 'top' | 'right' | 'left' | 'bottom'>;
}

export function SavedToast({ saveState, message, offsetStyle }: SavedToastProps) {
if (saveState === 'idle') return null;
const failed = saveState === 'failed';
const style: CSSProperties = {
position: 'absolute',
top: 16,
right: 16,
...offsetStyle,
zIndex: 5,
padding: '5px 12px',
borderRadius: 999,
border: failed
? '0.5px solid rgba(239,68,68,0.22)'
: '0.5px solid rgba(37,99,235,0.16)',
background: failed ? 'rgba(239,68,68,0.10)' : 'rgba(37,99,235,0.10)',
color: failed ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
fontSize: 11.5,
fontWeight: 500,
lineHeight: 1.4,
boxShadow: '0 4px 12px -4px rgba(15,17,22,0.18), 0 0 0 0.5px rgba(0,0,0,0.04)',
backdropFilter: 'blur(12px) saturate(160%)',
WebkitBackdropFilter: 'blur(12px) saturate(160%)',
pointerEvents: 'none',
animation: 'ol-toast-pop 0.22s var(--ol-motion-spring)',
whiteSpace: 'nowrap',
};
return (
<div role={failed ? 'alert' : 'status'} style={style}>
{message}
<style>{`
@keyframes ol-toast-pop {
from { opacity: 0; transform: translateY(-6px) scale(.96); filter: blur(4px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
`}</style>
</div>
);
}
56 changes: 48 additions & 8 deletions openless-all/app/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
// 开机自启)已从此弹窗移除,避免 "看似可点实际无效" 的负面体感。
// 待 backend 就位后再补回(参见 issue #69)。

import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { AboutUpdateControl, Settings as SettingsContent, Toggle, type SettingsSectionId } from '../pages/Settings';
import { Row } from './ui/Row';
import { SavedToast } from './SavedToast';
import { useSavedToastListener } from '../lib/savedEvent';
import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale';
import {
exportErrorLog,
Expand Down Expand Up @@ -55,6 +57,7 @@ const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
const { t } = useTranslation();
const [section, setSection] = useState<ModalSectionId>('settings');
const savedToast = useSavedToastListener();
const groups: ModalGroup[] = [
{
items: [
Expand All @@ -71,6 +74,16 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
},
];

// 与 sidebar nav 一致的滑动指示器:仅第一组(可选中)有 pill;外链组永远不 active 不画 pill。
const firstGroupRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
useLayoutEffect(() => {
const idx = groups[0].items.findIndex(it => it.id === section);
const el = firstGroupRefs.current[idx];
if (!el) return;
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
}, [section]);

return (
<div
onClick={onClose}
Expand Down Expand Up @@ -109,29 +122,49 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
}}>

{groups.map((g, gi) => (
<div key={gi} style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: gi === 1 ? 8 : 0, borderTop: gi === 1 ? '0.5px solid var(--ol-line-soft)' : 'none' }}>
{g.items.map((it) => {
<div key={gi} style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1, paddingTop: gi === 1 ? 8 : 0, borderTop: gi === 1 ? '0.5px solid var(--ol-line-soft)' : 'none' }}>
{gi === 0 && pillRect && (
<div
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
top: pillRect.top,
height: pillRect.height,
background: '#fff',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
{g.items.map((it, idx) => {
const active = section === it.id && !it.external;
return (
<button
key={it.id}
ref={gi === 0 ? (el => { firstGroupRefs.current[idx] = el; }) : undefined}
onClick={() => {
if (it.external && it.href) {
void openExternal(it.href);
} else {
setSection(it.id as ModalSectionId);
}
}}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: active ? '#fff' : 'transparent',
color: active ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
fontFamily: 'inherit', fontSize: 13, fontWeight: active ? 600 : 500,
boxShadow: active ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default', textAlign: 'left',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
position: 'relative',
zIndex: 1,
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
}}>

<Icon name={it.icon} size={14} />
Expand All @@ -148,6 +181,13 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
只有最里层的 scroll wrapper 真正滚动。这样模态左 sidebar、关闭按钮、
section 标题都不会跟着内容一起飘。 */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
{/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。
CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */}
<SavedToast
saveState={savedToast.state}
message={savedToast.message}
offsetStyle={{ top: 16, right: 54 }}
/>
<button
onClick={onClose}
style={{
Expand Down
64 changes: 64 additions & 0 deletions openless-all/app/src/lib/savedEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// savedEvent.ts — 跨组件的"已保存 / 失败"统一事件通道。
//
// 触发:任意组件保存成功 / 失败时调用 emitSaved(...)。
// 监听:根容器(Settings / Translation / SelectionAsk)通过 useSavedToastListener 订阅,
// 状态喂给 <SavedToast>,pill 浮在右上角。
//
// 用 DOM CustomEvent(而不是 React Context)是为了让 CredentialField / ProviderTools
// 这类深层叶子组件不必沿 props 链传 dispatcher,跟 NAVIGATE_LOCAL_ASR_EVENT 同惯例。

import { useEffect, useState } from 'react';

export const SAVED_TOAST_EVENT = 'openless:saved-toast';

export type SavedToastEventState = 'saving' | 'saved' | 'failed';

export interface SavedToastDetail {
state: SavedToastEventState;
message: string;
}

export function emitSaved(state: SavedToastEventState, message: string): void {
window.dispatchEvent(
new CustomEvent<SavedToastDetail>(SAVED_TOAST_EVENT, { detail: { state, message } }),
);
}

interface ToastSnapshot {
state: 'idle' | SavedToastEventState;
message: string;
}

const IDLE_SNAPSHOT: ToastSnapshot = { state: 'idle', message: '' };

/**
* 订阅 saved-toast 事件,自动管理"非 saving 状态 1.6s 后回 idle"逻辑。
* saving 状态保持显示直到下一条事件覆盖(避免长任务里 saving 中途消失)。
*/
export function useSavedToastListener(): ToastSnapshot {
const [snapshot, setSnapshot] = useState<ToastSnapshot>(IDLE_SNAPSHOT);
useEffect(() => {
let timer: number | null = null;
const handle = (event: Event) => {
const detail = (event as CustomEvent<SavedToastDetail>).detail;
if (!detail) return;
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
setSnapshot({ state: detail.state, message: detail.message });
if (detail.state !== 'saving') {
timer = window.setTimeout(() => {
setSnapshot(IDLE_SNAPSHOT);
timer = null;
}, 1600);
}
};
window.addEventListener(SAVED_TOAST_EVENT, handle);
return () => {
window.removeEventListener(SAVED_TOAST_EVENT, handle);
if (timer !== null) window.clearTimeout(timer);
};
}, []);
return snapshot;
}
29 changes: 1 addition & 28 deletions openless-all/app/src/pages/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,34 +124,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {

return (
<>
<PageHeader
kicker={t('overview.kicker')}
title={t('overview.title')}
desc={t('overview.desc')}
right={
<div
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 12px',
borderRadius: 999,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
color: 'var(--ol-ink-3)',
fontSize: 12,
}}
>
<Icon name="cmd" size={12} />
{t('overview.pressPrefix')}
<kbd style={{
padding: '2px 7px', fontSize: 11, fontFamily: 'var(--ol-font-mono)',
background: '#fff', borderRadius: 5,
border: '0.5px solid var(--ol-line-strong)',
color: 'var(--ol-ink)',
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
{t('overview.pressSuffix')}
</div>
}
/>
<PageHeader title={t('overview.title')} />

<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 18 }}>
<ProviderCard
Expand Down
Loading
Loading