mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-25 11:02:45 +01:00
Redesign spatial notifications and add streaming to the quick panel
This commit is contained in:
1 parent
1ed4b80fd3
commit
de54f93a73
8 files changed
+95
-14
No files matched your search
@@ -4,6 +4,7 @@ import {createRoot} from 'react-dom/client';
|
||||
import {NotificationCenter,type NotificationEntry} from '../ui/src/notifications';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
import '../ui/src/notification-surfaces.css';
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
|
||||
const plugin={manifest:{id:'capture.clip',name:'Clip capture',description:'Capture your session',version:'1.0.0',author:'Framely',ui:{windows:{}}},enabled:true,favorite:false,order:0};
|
||||
@@ -58,19 +59,19 @@ async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return;
|
||||
if(!entries[0].inInbox)throw Error('floating close removed saved message');
|
||||
render(true);await wait();button('移除通知').click();await until(()=>!!document.querySelector('.inbox-empty'));
|
||||
entries=[entry('expiry',{expiresAt:Date.now()+350})];render(false);await wait(700);
|
||||
if(document.querySelector('.toast')||!entries[0].inInbox)throw Error('automatic close removed inbox copy');
|
||||
if(document.querySelector('.toasts')||!entries[0].inInbox)throw Error('automatic close left an empty popup or removed inbox copy');
|
||||
entries=[entry('persistent',{expiresAt:null,notification:{id:'persistent',title:'Persistent status',body:'Stays visible until dismissed',durationMs:0}})];render(false);await wait();
|
||||
if(!document.querySelector('.toast')||document.querySelector('[role=timer]'))throw Error('persistent notification has a countdown');
|
||||
entries=[entry('long',{notification:{id:'long',title:'A recording with a long title is ready to review',body:'Recording details. '.repeat(80),actions:[{id:'open',label:'Open recording'},{id:'copy',label:'Copy path'},{id:'continue',label:'Continue recording'}]}})];render(false);await wait();
|
||||
const compact=document.querySelector('.toast') as HTMLElement,message=compact.querySelector('.notification-message') as HTMLElement;
|
||||
if(compact.getBoundingClientRect().height>185||message.scrollHeight<=message.clientHeight)throw Error('long notification expanded instead of scrolling inside compact popup');
|
||||
if(compact.getBoundingClientRect().height>233||message.scrollHeight<=message.clientHeight)throw Error('long notification expanded instead of scrolling inside compact popup');
|
||||
for(const action of compact.querySelectorAll('.toast-actions button')){const bounds=action.getBoundingClientRect();if(bounds.height<44||bounds.bottom>compact.getBoundingClientRect().bottom)throw Error('compact notification action is clipped or too small');}
|
||||
entries=[entry('saved',{notification:{id:'saved',title:'Recording saved',body:'Your recording is ready. Open it whenever you are ready.',actions:[{id:'open',label:'Open recording'}]}}),entry('update',{plugin:'',pluginName:'Framely',update:{version:'0.4.3-preview.1'},notification:{id:'update',title:'Update',body:'New release',actions:[{id:'ignore',label:'Ignore'},{id:'open',label:'Open updates'}]}})];
|
||||
configureLanguage('en-US',[]);render(true);await wait(180);
|
||||
if(!document.body.textContent!.includes('Inbox')||!document.body.textContent!.includes('Open updates'))throw Error('English translation missing');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_INBOX_EN');
|
||||
render(false);await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_TOAST_EN');
|
||||
if([...document.querySelectorAll('.toast')].some(card=>card.getBoundingClientRect().height>185))throw Error('floating notification exceeds compact height');
|
||||
if([...document.querySelectorAll('.toast')].some(card=>card.getBoundingClientRect().height>233))throw Error('floating notification exceeds compact height');
|
||||
if([...document.querySelectorAll('.toast-actions button')].some(b=>b.scrollWidth>b.clientWidth))throw Error('notification action overflowed');
|
||||
// Mount the real quick menu and assert the navigation actually reaches the inbox.
|
||||
root.unmount();await import('../ui/src/main');
|
||||
@@ -80,7 +81,7 @@ async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return;
|
||||
if(document.querySelectorAll('.plugin-row').length!==1)throw Error('quick installed list includes disabled plugins');
|
||||
const notifications=[...document.querySelectorAll('nav button')].find(b=>b.textContent?.startsWith('通知')) as HTMLButtonElement;
|
||||
if(!notifications)throw Error('quick inbox tab missing');notifications.click();await wait(180);
|
||||
if(document.querySelectorAll('.inbox-message').length!==2||document.querySelectorAll('nav button').length!==4)throw Error('quick inbox navigation broken');
|
||||
if(document.querySelectorAll('.inbox-message').length!==2||document.querySelectorAll('nav button').length!==5)throw Error('quick inbox navigation broken');
|
||||
if(!document.querySelector('[role=switch][aria-label="允许通知"]')||!document.querySelector('[aria-label="通知设置"]'))throw Error('quick notification controls missing');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_QUICK_INBOX');
|
||||
button('打开更新').click();await until(()=>!document.querySelector('.inbox-message')?.textContent?.includes('可更新'));
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>any)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,350));};
|
||||
history.replaceState(null,'','/?fixture=1#plugin:test.quick');
|
||||
let polls=0,opens=0;
|
||||
let polls=0,opens=0,streaming=false;
|
||||
const casting={source:'screen',codec:'h265',output:'eye',eye:'steamVR',width:1920,height:1200,fps:60,bitrateMbps:8,horizontalFov:null,centerX:.5,centerY:.5,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely'};
|
||||
window.fetch=async(_url,init)=>{
|
||||
const {method}=JSON.parse(String(init?.body));let result:any=true;
|
||||
if(method==='status'){polls++;result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{language:'en-US',plugins:{'test.quick':{manifest:{id:'test.quick',name:'Quick fixture',version:'1',author:'Test',description:'',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};}
|
||||
if(method==='status'){polls++;result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{casting,language:'en-US',plugins:{'test.quick':{manifest:{id:'test.quick',name:'Quick fixture',version:'1',author:'Test',description:'',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};}
|
||||
else if(method==='language.list')result={packs:[],invalidFiles:[]};
|
||||
else if(method==='ui.events')result={cursor:0,events:[]};
|
||||
else if(method==='plugin.open')opens++;
|
||||
else if(method==='cast.status')result={running:streaming};
|
||||
else if(method==='cast.start')streaming=true;
|
||||
else if(method==='cast.stop')streaming=false;
|
||||
else if(method==='cast.settings.save'){const params=JSON.parse(String(init?.body)).params;if(params.width!==1920||params.codec!=='h265')throw Error('Receiver switch reset stream settings');Object.assign(casting,params);}
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
const back=()=>document.querySelector<HTMLButtonElement>('.page-title button')!;
|
||||
@@ -26,6 +31,11 @@ const back=()=>document.querySelector<HTMLButtonElement>('.page-title button')!;
|
||||
window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));await until(()=>!!document.querySelector('.workspace'));
|
||||
const third=polls;await until(()=>polls>=third+2);
|
||||
if(back()||location.hash||opens!==3)throw Error('Escape reopened plugin');
|
||||
const streamTab=[...document.querySelectorAll<HTMLButtonElement>('nav button')].find(b=>b.textContent==='Streaming');if(!streamTab)throw Error('Streaming tab missing');streamTab.click();await until(()=>!!document.querySelector('.cast-quick'));
|
||||
const start=[...document.querySelectorAll<HTMLButtonElement>('.cast-quick button')].find(b=>b.textContent==='Start streaming')!;await until(()=>!start.disabled);start.click();await until(()=>streaming&&document.querySelector('.cast-quick-stream button')?.textContent==='Stop streaming');
|
||||
const receiver=document.querySelector<HTMLButtonElement>('button[role=switch][aria-label=AirPlay]')!;await until(()=>!receiver.disabled);receiver.click();await until(()=>casting.airplay&&receiver.getAttribute('aria-checked')==='true');
|
||||
document.querySelector<HTMLButtonElement>('.cast-quick-stream button')!.click();await until(()=>!streaming);
|
||||
if(document.documentElement.scrollWidth>innerWidth)throw Error('Quick panel overflows viewport');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
|
||||
};
|
||||
|
||||
@@ -1227,6 +1227,8 @@
|
||||
"通用兼容": "Broad compatibility",
|
||||
"更高压缩效率": "Better compression",
|
||||
"使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。": "Uses headset hardware encoding. Browsers that cannot decode the selected codec receive a compatible stream.",
|
||||
"SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR uses software encoding. H.265 increases processing load."
|
||||
"SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR uses software encoding. H.265 increases processing load.",
|
||||
"串流": "Streaming",
|
||||
"投屏设置": "Casting settings"
|
||||
}
|
||||
}
|
||||
@@ -1227,6 +1227,8 @@
|
||||
"通用兼容": "通用兼容",
|
||||
"更高压缩效率": "更高压缩效率",
|
||||
"使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。": "使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。",
|
||||
"SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR 使用软件编码,H.265 会增加处理负载。"
|
||||
"SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR 使用软件编码,H.265 会增加处理负载。",
|
||||
"串流": "串流",
|
||||
"投屏设置": "投屏设置"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {IconCast,IconPlayerPlay,IconPlayerStop,IconArrowUpRight,IconDeviceVisionPro} from '@tabler/icons-react';
|
||||
import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import {Switch} from './switch';
|
||||
import type {CastSettings} from './casting';
|
||||
|
||||
type Runtime={running:boolean;error?:string;receiverError?:string;watchAddresses?:string[]};
|
||||
export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Promise<void>}){
|
||||
const [runtime,setRuntime]=useState<Runtime|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{let alive=true;const poll=()=>void api<Runtime>('cast.status').then(v=>{if(alive)setRuntime(v);}).catch(e=>{if(alive)setError(e.message);});poll();const timer=setInterval(poll,1000);return()=>{alive=false;clearInterval(timer);};},[]);
|
||||
async function run(task:()=>Promise<unknown>){if(busy)return;setBusy(true);setError('');try{await task();setRuntime(await api('cast.status'));await refresh();}catch(e){setError(e instanceof Error?e.message:String(e));}finally{setBusy(false);}}
|
||||
const running=!!runtime?.running;
|
||||
return <section className="cast-quick">
|
||||
<header><IconCast size={25} stroke={1.8}/><h1>{t('串流')}</h1><button aria-label={t('投屏设置')} onClick={()=>void run(()=>api('host.manager.open',{page:'casting'}))}><IconArrowUpRight size={22}/></button></header>
|
||||
<div className="cast-quick-stream"><IconDeviceVisionPro size={40} stroke={1.5}/><h2>{running?t('正在串流'):t('头显画面')}</h2><p>{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}</p><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button></div>
|
||||
{running&&runtime?.watchAddresses?.[0]&&<div className="cast-quick-address"><span>{t('网页观看')}</span><code>{runtime.watchAddresses[0]}</code></div>}
|
||||
<div className="cast-quick-receive"><h2>{t('接收投屏')}</h2>{(['airplay','dlna'] as const).map(protocol=><label key={protocol}><span>{protocol==='airplay'?'AirPlay':'DLNA'}</span><Switch label={protocol==='airplay'?'AirPlay':'DLNA'} checked={config[protocol]} disabled={busy} onChange={value=>void run(()=>api('cast.settings.save',{...config,[protocol]:value}))}/></label>)}</div>
|
||||
{(error||runtime?.error||runtime?.receiverError)&&<p className="error" role="alert">{error||runtime?.error||runtime?.receiverError}</p>}
|
||||
</section>;
|
||||
}
|
||||
+5
-3
@@ -1,3 +1,4 @@
|
||||
import {CastQuick} from './cast-quick';
|
||||
import {CastPlayer} from './cast-player';
|
||||
import {Casting,type CastSettings,type CastPage} from './casting';
|
||||
import {CastViewer} from './cast-viewer';
|
||||
@@ -12,7 +13,7 @@ import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from '
|
||||
import {Launcher,LauncherSettings,type LauncherConfig} from './launcher';
|
||||
import {NotificationSettings,PopupPermission,useReadInbox,defaultNotificationConfig,type NotificationConfig} from './notification-settings';
|
||||
import {NotificationCenter,type NotificationEntry} from './notifications';
|
||||
import {IconBell,IconLayoutGrid} from '@tabler/icons-react';
|
||||
import {IconBell,IconLayoutGrid,IconCast} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
import {uploadFile} from './upload';
|
||||
import {LanguageSettings} from './language';
|
||||
@@ -26,7 +27,7 @@ import {InstallReview} from './install-review';
|
||||
import {MemoryLimit} from './memory-limit';
|
||||
import {installScrollbars} from '../../sdk/src/scrollbars';
|
||||
installScrollbars();
|
||||
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey,exportDiagnosticLogs}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';import './mobile-manager.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
|
||||
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey,exportDiagnosticLogs}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';import './mobile-manager.css';import './notification-surfaces.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
|
||||
export type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record<string,unknown>;optionalDependencies?:Record<string,unknown>;conflicts?:Record<string,string>;exclusiveResources?:string[];engines?:{framely?:string};ui:{quickPage?:string;windows:Record<string,unknown>;launch?:Record<string,{type:string;window?:string}>;launcherActions?:{id:string;label:string;target:{type:string;entry?:string;window?:string}}[]}};
|
||||
export type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string};
|
||||
type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
|
||||
@@ -97,7 +98,7 @@ function App(){
|
||||
if(parts[1]==='notifications')return <NotificationCenter items={status.notifications} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh}/>;
|
||||
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app plugin-window">{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<div className="empty"><p>{t("插件已停用或正在启动。")}</p><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></div>}{error&&<p className="error">{error}</p>}</div>;}
|
||||
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<>{managerSection==='plugins'&&<button className="primary manager-install-button" onClick={()=>setImporting(true)}><Icon name="download" size={20}/>{t("安装插件")}</button>}<ManagerSwitcher section={managerSection} onChange={switchManager}/>{mobile&&<MobilePagePicker section={managerSection} tab={tab} onChange={key=>key==='catalog'?void loadCatalog():(setPage(null),setTab(key),history.replaceState(null,'',location.pathname+location.search+'#'+key))}/> }<button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className={`workspace ${managerMode&&(tab==='terminal'||tab==='files')?'tools-workspace':''}`}><nav aria-label={t("主导航")}>{(managerMode?managerPages(managerSection):[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():(setTab(key),history.replaceState(null,'',location.pathname+location.search+'#'+key))}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:icon==='launcher'?<IconLayoutGrid size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{managerSection==='casting'?t("头显与局域网设备"):apkPanel?t("Lepton 侧载管理"):<>{plugins.length}{t("个已安装插件")}</>}</span><small>{status.version}</small></div>}</nav><main className={`content ${managerMode&&tab==='files'?'file-content':managerMode&&tab==='terminal'?'terminal-content':''}`}>
|
||||
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className={`workspace ${managerMode&&(tab==='terminal'||tab==='files')?'tools-workspace':''}`}><nav aria-label={t("主导航")}>{(managerMode?managerPages(managerSection):[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['casting',t('串流'),'cast'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():(setTab(key),history.replaceState(null,'',location.pathname+location.search+'#'+key))}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:icon==='cast'?<IconCast size={24} stroke={1.8}/>:icon==='launcher'?<IconLayoutGrid size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{managerSection==='casting'?t("头显与局域网设备"):apkPanel?t("Lepton 侧载管理"):<>{plugins.length}{t("个已安装插件")}</>}</span><small>{status.version}</small></div>}</nav><main className={`content ${managerMode&&tab==='files'?'file-content':managerMode&&tab==='terminal'?'terminal-content':''}`}>
|
||||
{(tab==='favorites'||tab==='installed')&&<>
|
||||
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
|
||||
{managerMode&&<div className="list-columns"><span>{t("插件")}</span><span>{t("状态")}</span><span>{t("版本")}</span><span>{t("操作")}</span></div>}<div className="plugin-list">{listedPlugins.map(p=><article className={`plugin-row ${!p.enabled?'disabled':''}`} key={p.manifest.id}>
|
||||
@@ -107,6 +108,7 @@ function App(){
|
||||
</article>)}</div>
|
||||
{listedPlugins.length===0&&<div className="empty-state"><span className="empty-symbol"><Icon name={tab==='favorites'?'star':'plugins'} size={46}/></span><h2>{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):onlyDisabledInstalled?t("没有已启用的插件"):t("还没有安装插件")}</h2><p>{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):onlyDisabledInstalled?t("打开插件管理以启用插件。"):t("打开插件管理以安装插件。")}</p><button className="primary" onClick={()=>query?setQuery(''):tab==='favorites'?setTab('installed'):managerMode?setImporting(true):void api('host.manager.open')}>{query?t("清除搜索"):tab==='favorites'?t("查看已安装"):managerMode?t("安装插件"):t("打开插件管理")}</button></div>}
|
||||
</>}
|
||||
{tab==='casting'&&!managerMode&&status.database.casting&&<CastQuick config={status.database.casting} refresh={refresh}/>}
|
||||
{tab==='notifications'&&!managerMode&&<><NotificationCenter inbox items={status.inbox??[]} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh} controls={<PopupPermission config={status.database.notificationSettings??defaultNotificationConfig} refresh={refresh}/>}/>{readError&&<p className="error" role="alert">{readError}</p>}</>}
|
||||
{managerMode&&(filesVisited||tab==='files')&&<div className="file-workspace-frame" hidden={tab!=='files'}><FileManager active={tab==='files'}/></div>}
|
||||
{tab==='terminal'&&managerMode&&<TerminalManager/>}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* Spatial controls retain Framely's dark material and single blue accent.
|
||||
Panels use 16px corners; compact actions use pill corners. */
|
||||
.notification-view #root,.notification-view .toasts{background:transparent}
|
||||
.notification-view .toasts:empty{display:none}
|
||||
.toasts{padding:12px;scrollbar-width:none}
|
||||
.toasts .notification-card{padding:16px 18px;max-height:232px;margin:0 0 10px;border-radius:16px;border:1px solid #ffffff20;background:rgba(25,27,30,.97);box-shadow:inset 0 1px 0 #ffffff0c;gap:0}
|
||||
.toasts .notification-heading{min-height:28px;gap:10px;margin-bottom:12px}
|
||||
.toasts .notification-source-icon{width:24px;height:24px;color:var(--blue)}
|
||||
.toasts .notification-origin{gap:12px;align-items:center}
|
||||
.toasts .notification-origin b{font-size:15px;font-weight:550;color:var(--muted)}
|
||||
.toasts .notification-countdown{font-size:12px;color:var(--muted);margin-left:auto}
|
||||
.toasts .notification-dismiss{border:0;background:transparent;color:var(--muted);width:36px;min-height:36px;height:36px;border-radius:999px;padding:8px}
|
||||
.toasts .notification-dismiss:hover{background:var(--control-hover);color:var(--text)}
|
||||
.toasts .notification-message{max-height:88px;overflow:auto;scrollbar-width:thin}
|
||||
.toasts .notification-message h2{font-size:22px;line-height:1.3;letter-spacing:-.35px;margin:0 0 6px}
|
||||
.toasts .notification-message p{font-size:17px;line-height:1.45;color:#c7cad0}
|
||||
.toasts .notification-card .toast-actions{gap:10px;margin-top:16px;justify-content:flex-end}
|
||||
.toasts .notification-card .toast-actions button{min-width:76px;min-height:44px;padding:10px 18px;border-radius:999px;border:1px solid #ffffff1a;background:#ffffff09;font-size:15px;font-weight:550}
|
||||
.toasts .notification-card .toast-actions .notification-primary{background:var(--blue);border-color:var(--blue);color:#152331}
|
||||
.toasts .notification-card .toast-actions .notification-primary:hover{background:#b5d7f2}
|
||||
.notification-inbox .notification-card{border-radius:16px;padding:22px;border-color:#ffffff18;background:#ffffff05}
|
||||
.notification-inbox .notification-card .toast-actions button{border-radius:999px}
|
||||
.notification-inbox .notification-card .toast-actions .notification-primary{background:var(--blue);color:#152331;border-color:var(--blue)}
|
||||
.cast-quick{padding:4px 0 20px}
|
||||
.cast-quick>header{display:flex;align-items:center;gap:12px;margin-bottom:24px;color:var(--blue)}
|
||||
.cast-quick>header h1{font-size:28px;color:var(--text);flex:1}
|
||||
.cast-quick>header button{width:44px;min-height:44px;padding:10px;background:transparent;border:0;border-radius:999px}
|
||||
.cast-quick-stream{display:flex;flex-direction:column;align-items:center;padding:24px 16px 28px;border-radius:16px;background:#ffffff06;border:1px solid #ffffff14}
|
||||
.cast-quick-stream>svg{color:var(--blue);margin-bottom:18px}
|
||||
.cast-quick-stream h2{font-size:24px;margin-bottom:6px}
|
||||
.cast-quick-stream p{font-size:16px;color:var(--muted);font-variant-numeric:tabular-nums;margin:0 0 22px}
|
||||
.cast-quick-stream button{min-width:180px;border-radius:999px}
|
||||
.cast-quick-stream button.primary{background:var(--blue);color:#152331;border-color:var(--blue)}
|
||||
.cast-quick-address{display:grid;gap:8px;padding:18px 0;color:var(--muted);font-size:16px}
|
||||
.cast-quick-address code{font:15px/1.5 ui-monospace,monospace;color:var(--text);overflow-wrap:anywhere;user-select:all}
|
||||
.cast-quick-receive{margin-top:28px}
|
||||
.cast-quick-receive h2{font-size:17px;font-weight:500;color:var(--muted);margin-bottom:8px}
|
||||
.cast-quick-receive label{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:14px 0}
|
||||
.cast-quick-receive .ui-switch{flex:none}
|
||||
.quick nav button>span{font-size:16px}
|
||||
@media(max-width:480px){.toasts .notification-card{padding:14px}.toasts .notification-heading{gap:8px}.toasts .notification-origin{flex-direction:row;gap:6px}.toasts .notification-countdown{font-size:11px}.toasts .notification-card .toast-actions button{flex:1;padding:10px 12px}.notification-inbox .notification-card{padding:18px}}
|
||||
@media(prefers-reduced-motion:no-preference){.notification-card button,.cast-quick button{transition:background .15s,transform .15s}.notification-card button:active:not(:disabled),.cast-quick button:active:not(:disabled){transform:scale(.98)}}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {IconBell,IconInbox,IconX,IconTrash,IconArrowUpRight} from '@tabler/icons-react';
|
||||
import {IconBell,IconInbox,IconX,IconTrash,IconArrowUpRight,IconCast} from '@tabler/icons-react';
|
||||
import {api} from './api';
|
||||
import {t,currentLanguage} from './i18n';
|
||||
import {Brand} from './icons';
|
||||
@@ -23,7 +23,7 @@ function NotificationCard({entry,inbox,now,refresh,available}:{entry:Notificatio
|
||||
}
|
||||
return <article className={`notification-card ${inbox?'inbox-message':'toast'}`}>
|
||||
<header className="notification-heading">
|
||||
<span className="notification-source-icon">{entry.update?<Brand/>:<IconBell size={22} stroke={1.8}/>}</span>
|
||||
<span className="notification-source-icon">{entry.notification.id.startsWith('cast.')?<IconCast size={22} stroke={1.8}/>:entry.update?<Brand/>:<IconBell size={22} stroke={1.8}/>}</span>
|
||||
<div className="notification-origin"><b>{entry.pluginName}</b>{inbox?<time dateTime={new Date(entry.createdAt).toISOString()}>{new Intl.DateTimeFormat(currentLanguage(),{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}).format(entry.createdAt)}</time>:remaining!==null&&<span className="notification-countdown" role="timer">{t('{0} 秒后关闭',{0:remaining})}</span>}</div>
|
||||
<button className="notification-dismiss" disabled={busy} aria-label={inbox?t('移除通知'):t('关闭通知')} onClick={()=>void perform(inbox?'notification.remove':'notification.dismiss')}>{inbox?<IconTrash size={21} stroke={1.8}/>:<IconX size={23} stroke={1.8}/>}</button>
|
||||
</header>
|
||||
@@ -31,7 +31,7 @@ function NotificationCard({entry,inbox,now,refresh,available}:{entry:Notificatio
|
||||
{!available&&<p className="notification-unavailable">{t('启用此插件后可使用通知操作。')}</p>}
|
||||
{!!entry.notification.actions?.length&&<footer className="toast-actions">{entry.notification.actions.map(a=>{
|
||||
const label=entry.update?(a.id==='ignore'?t('忽略'):t('打开更新')):a.label;
|
||||
return <button key={a.id} className={entry.update&&a.id==='open'?'notification-primary':''} aria-label={label} title={label} disabled={busy||!available} onClick={()=>void perform('notification.action',a.id)}><span className="notification-action-label">{label}</span>{entry.update&&a.id==='open'&&<IconArrowUpRight size={19} stroke={1.8}/>}</button>;
|
||||
return <button key={a.id} className={a.id==='open'?'notification-primary':''} aria-label={label} title={label} disabled={busy||!available} onClick={()=>void perform('notification.action',a.id)}><span className="notification-action-label">{label}</span>{entry.update&&a.id==='open'&&<IconArrowUpRight size={19} stroke={1.8}/>}</button>;
|
||||
})}</footer>}
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
</article>;
|
||||
@@ -40,6 +40,7 @@ export function NotificationCenter({items,inbox=false,refresh,plugins={},safeMod
|
||||
const[now,setNow]=useState(Date.now());
|
||||
useEffect(()=>{if(inbox)return;const timer=setInterval(()=>setNow(Date.now()),250);return()=>clearInterval(timer);},[inbox]);
|
||||
const visible=items.filter(n=>inbox?n.inInbox:n.toast&&(n.expiresAt===null||n.expiresAt>now)).sort((a,b)=>b.createdAt-a.createdAt);
|
||||
if(!inbox&&!visible.length)return null;
|
||||
return <section className={inbox?'notification-inbox':'toasts'} aria-label={t('通知')}>
|
||||
{inbox&&<header className="inbox-heading"><div><h1>{t('通知')}</h1><p>{t('收件箱')}<span>{visible.length}</span></p></div><IconInbox size={30} stroke={1.8}/></header>}
|
||||
{inbox&&controls}
|
||||
|
||||
Reference in new issue
Block a user