This commit is contained in:
+3
-1
@@ -16,6 +16,7 @@ import { useTaskNotifications } from './hooks/useTaskNotifications';
|
||||
import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications';
|
||||
import { COLUMN_LIST, MOBILE_TAB_LIST, type MobileTabId, type PageId } from './lib/urlState';
|
||||
import { confirmDiscardUnsaved } from './lib/unsavedGuard';
|
||||
import { useBackdropClose } from './lib/useBackdropClose';
|
||||
import { TopBar } from './components/layout/TopBar';
|
||||
import { NavDrawer } from './components/layout/NavDrawer';
|
||||
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
|
||||
@@ -157,6 +158,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
setOverride((o) => (o && o.key === overrideKey ? o : null));
|
||||
}, [overrideKey]);
|
||||
const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
|
||||
const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false));
|
||||
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
const compactMode = useCompactNav(isAdmin, authEnabled);
|
||||
@@ -632,7 +634,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
|
||||
{/* Tablet: detail overlay */}
|
||||
{tabletDetailOpen && panelOpen && (
|
||||
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" onClick={() => setTabletDetailOpen(false)}>
|
||||
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
|
||||
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
|
||||
{localTaskId && (
|
||||
<LocalDetailPanel
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardWidgetKind } from '../../api';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -35,6 +36,7 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
|
||||
const [title, setTitle] = useState('');
|
||||
const [kind, setKind] = useState<DashboardWidgetKind>('markdown');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const backdrop = useBackdropClose(() => { if (!saving) onClose(); });
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -46,7 +48,7 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={() => !saving && onClose()}
|
||||
{...backdrop}
|
||||
>
|
||||
<div
|
||||
className="bg-surface rounded-md shadow-lg w-[320px] p-4 flex flex-col gap-3"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { resolvePieceOptions } from '../../lib/splitPieces';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
interface PrevJobInfo {
|
||||
id: string;
|
||||
@@ -26,6 +27,7 @@ export function ContinueWithPieceDialog({
|
||||
const [instruction, setInstruction] = useState<string>('');
|
||||
const [resultExpanded, setResultExpanded] = useState<boolean>(false);
|
||||
const qc = useQueryClient();
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
const piecesQuery = usePieceList();
|
||||
|
||||
@@ -65,7 +67,7 @@ export function ContinueWithPieceDialog({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
{...backdrop}
|
||||
>
|
||||
<div className="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
interface EmbedModalProps {
|
||||
open: boolean;
|
||||
@@ -8,6 +9,7 @@ interface EmbedModalProps {
|
||||
}
|
||||
|
||||
export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}, [onClose]);
|
||||
@@ -28,10 +30,10 @@ export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
{/* Backdrop — the close handler lives here (the actual clicked element),
|
||||
not the transparent outer div, so target===currentTarget holds. */}
|
||||
<div className="absolute inset-0 bg-black/50" {...backdrop} />
|
||||
|
||||
{/* Modal content */}
|
||||
<div
|
||||
|
||||
@@ -7,6 +7,7 @@ import hljs from 'highlight.js';
|
||||
import { updateLocalFileContent } from '../../api';
|
||||
import { EmbedBlock } from '../embed/EmbedBlock';
|
||||
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
||||
|
||||
@@ -625,6 +626,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
const [error, setError] = useState('');
|
||||
const [currentContent, setCurrentContent] = useState(content);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
const canEdit = editable && taskId != null && section && filePath;
|
||||
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
|
||||
@@ -734,7 +736,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
const modalWidth = 'min(1400px, 96vw)';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" onClick={onClose}>
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" {...backdrop}>
|
||||
<div
|
||||
className="bg-surface rounded-md border border-hairline shadow-md flex flex-col overflow-hidden"
|
||||
style={{ width: modalWidth, maxHeight: 'min(90vh, calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px))' }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import type { PageId } from '../../lib/urlState';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
export interface NavItem {
|
||||
id: PageId;
|
||||
@@ -97,6 +98,7 @@ export function NavDrawer({
|
||||
}: NavDrawerProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const firstItemRef = useRef<HTMLButtonElement>(null);
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -151,7 +153,7 @@ export function NavDrawer({
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
|
||||
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Authentication config (`auth.*`): session policy, admin allowlist, and the
|
||||
* Google / Gitea OAuth providers. Secrets (session secret, client secrets) are
|
||||
* server-masked (config-manager SENSITIVE_PATHS) — they render as ******** and
|
||||
* saving without re-entering keeps the stored value.
|
||||
*
|
||||
* Leaving the whole `auth` block empty (no providers) keeps the app in no-auth
|
||||
* mode — every visitor is treated as a local admin.
|
||||
*/
|
||||
export function AuthForm({ config, onChange }: SectionFormProps) {
|
||||
const auth = config.auth ?? {};
|
||||
const providers = auth.providers ?? {};
|
||||
const google = providers.google ?? {};
|
||||
const gitea = providers.gitea ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-base font-semibold text-slate-800">Authentication</h2>
|
||||
<p className="text-[13px] text-slate-500">
|
||||
ログイン認証。providers を未設定にすると <strong>認証なし(全員ローカル admin)</strong>で動作する。
|
||||
変更後はサーバ再起動が必要。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Primary Provider</FieldLabel>
|
||||
<select
|
||||
value={auth.primaryProvider ?? ''}
|
||||
onChange={e => onChange('auth.primaryProvider', e.target.value)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
|
||||
>
|
||||
<option value="">(指定なし・両方有効)</option>
|
||||
<option value="google">google のみ</option>
|
||||
<option value="gitea">gitea のみ</option>
|
||||
</select>
|
||||
<HelpText>単一プロバイダーに限定する場合に指定。未指定なら設定済みの全プロバイダーでログイン可</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Admin Emails</FieldLabel>
|
||||
<StringArrayEditor
|
||||
value={auth.adminEmails ?? []}
|
||||
onChange={v => onChange('auth.adminEmails', v)}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
<HelpText>admin ロールを付与するメールアドレス</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" checked={auth.secureCookie === true}
|
||||
onChange={e => onChange('auth.secureCookie', e.target.checked)} className="rounded" />
|
||||
Secure Cookie(HTTPS のみで Cookie 送信)
|
||||
</label>
|
||||
<HelpText>本番(HTTPS)では有効推奨。HTTP のローカル開発では無効</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Session Max Age (ms)</FieldLabel>
|
||||
<FieldInput type="number" value={auth.sessionMaxAge ?? ''}
|
||||
onChange={v => onChange('auth.sessionMaxAge', v ? Number(v) : undefined)} />
|
||||
<HelpText>セッションの有効期間(ミリ秒)</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Session Secret</FieldLabel>
|
||||
<FieldInput type="password" value={auth.sessionSecret ?? ''}
|
||||
onChange={v => onChange('auth.sessionSecret', v)} />
|
||||
<HelpText>セッション署名鍵。十分長いランダム文字列を設定(保存後はマスク表示)</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Google OAuth</h3>
|
||||
<div>
|
||||
<FieldLabel>Client ID</FieldLabel>
|
||||
<FieldInput value={google.clientId ?? ''}
|
||||
onChange={v => onChange('auth.providers.google.clientId', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Client Secret</FieldLabel>
|
||||
<FieldInput type="password" value={google.clientSecret ?? ''}
|
||||
onChange={v => onChange('auth.providers.google.clientSecret', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Callback URL</FieldLabel>
|
||||
<FieldInput value={google.callbackUrl ?? ''} placeholder="https://example.com/auth/google/callback"
|
||||
onChange={v => onChange('auth.providers.google.callbackUrl', v)} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Gitea OAuth</h3>
|
||||
<div>
|
||||
<FieldLabel>Base URL</FieldLabel>
|
||||
<FieldInput value={gitea.baseUrl ?? ''} placeholder="https://gitea.example.com"
|
||||
onChange={v => onChange('auth.providers.gitea.baseUrl', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Client ID</FieldLabel>
|
||||
<FieldInput value={gitea.clientId ?? ''}
|
||||
onChange={v => onChange('auth.providers.gitea.clientId', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Client Secret</FieldLabel>
|
||||
<FieldInput type="password" value={gitea.clientSecret ?? ''}
|
||||
onChange={v => onChange('auth.providers.gitea.clientSecret', v)} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Callback URL</FieldLabel>
|
||||
<FieldInput value={gitea.callbackUrl ?? ''} placeholder="https://example.com/auth/gitea/callback"
|
||||
onChange={v => onChange('auth.providers.gitea.callbackUrl', v)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,13 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
||||
onChange={v => onChange('browser.maxSessions', Number(v))} />
|
||||
<HelpText>同時に起動できるセッションの最大数。デフォルト: 3</HelpText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Task Session Idle TTL (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={browser.taskSessionIdleTtl ?? ''}
|
||||
onChange={v => onChange('browser.taskSessionIdleTtl', v ? Number(v) : undefined)} />
|
||||
<HelpText>タスク用 CDP セッションをアイドル後に破棄するまでの秒数(GC)。デフォルト: 300</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { SshForm } from './SshForm';
|
||||
import { GatewayServerForm } from './GatewayServerForm';
|
||||
import { NotesForm } from './NotesForm';
|
||||
import { PushNotificationsForm } from './PushNotificationsForm';
|
||||
import { AuthForm } from './AuthForm';
|
||||
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
@@ -189,6 +190,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
case 'reflection': return <ReflectionForm {...formProps} />;
|
||||
case 'notes': return <NotesForm {...formProps} />;
|
||||
case 'push-notifications': return <PushNotificationsForm {...formProps} />;
|
||||
case 'auth': return <AuthForm {...formProps} />;
|
||||
|
||||
// ── Tools sub-sections — Step 9 split the legacy grab-bag
|
||||
// ToolsForm into focused per-category forms. Each binds to the
|
||||
|
||||
@@ -34,6 +34,7 @@ const CONFIG_GROUPS = [
|
||||
{ id: 'branding', label: 'Branding' },
|
||||
{ id: 'paths-storage', label: 'Paths & Storage' },
|
||||
{ id: 'execution', label: 'Execution' },
|
||||
{ id: 'auth', label: 'Authentication' },
|
||||
{ id: 'push-notifications', label: 'Web Push (Server)' },
|
||||
],
|
||||
},
|
||||
@@ -68,6 +69,7 @@ const CONFIG_GROUPS = [
|
||||
{ id: 'tools-browser', label: 'Browser Runtime' },
|
||||
{ id: 'tools-media', label: 'Media & Documents' },
|
||||
{ id: 'tools-external', label: 'External Services' },
|
||||
{ id: 'search-filter', label: 'Search Filter' },
|
||||
{ id: 'tools-legacy-knowledge', label: 'Legacy Knowledge' },
|
||||
],
|
||||
},
|
||||
@@ -102,7 +104,6 @@ export const LEGACY_SECTION_REDIRECT: Record<string, string> = {
|
||||
workspace: 'paths-storage',
|
||||
tools: 'tools-web',
|
||||
'browser-settings': 'tools-browser',
|
||||
'search-filter': 'tools-web',
|
||||
// browser-sessions never had a Settings page in the new layout —
|
||||
// it lives in User Folder. Keep mapping so an old URL still goes
|
||||
// somewhere sensible.
|
||||
|
||||
@@ -63,6 +63,39 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
placeholder="/path/to/chrome/profile" />
|
||||
<HelpText>Cookie 抽出用の Chrome プロファイルディレクトリ。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Media Download</FieldLabel>
|
||||
<select value={tools.xDownloadMedia ?? 'auto'}
|
||||
onChange={e => onChange('tools.xDownloadMedia', e.target.value)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow">
|
||||
<option value="auto">auto(画像/メディアを自動取得・既定)</option>
|
||||
<option value="never">never(取得しない)</option>
|
||||
</select>
|
||||
<HelpText>X 投稿の画像等メディアの自動ダウンロード。デフォルト: auto</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Video Download</FieldLabel>
|
||||
<select value={tools.xDownloadVideo ?? 'thumbnail'}
|
||||
onChange={e => onChange('tools.xDownloadVideo', e.target.value)}
|
||||
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow">
|
||||
<option value="thumbnail">thumbnail(サムネイルのみ・既定)</option>
|
||||
<option value="full">full(動画本体を取得)</option>
|
||||
<option value="never">never(取得しない)</option>
|
||||
</select>
|
||||
<HelpText>X 投稿の動画の取得モード。デフォルト: thumbnail(帯域節約)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Media Max (MB)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.xMediaMaxMb ?? ''}
|
||||
onChange={v => onChange('tools.xMediaMaxMb', v ? Number(v) : undefined)} />
|
||||
<HelpText>1 メディアあたりの最大ダウンロードサイズ(MB)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>X Media Fetch Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.xMediaFetchTimeoutSeconds ?? ''}
|
||||
onChange={v => onChange('tools.xMediaFetchTimeoutSeconds', v ? Number(v) : undefined)} />
|
||||
<HelpText>メディア取得のタイムアウト(秒)</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
@@ -74,6 +107,12 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
||||
<FieldInput type="password" value={tools.googleMapsApiKey ?? ''} onChange={v => onChange('tools.googleMapsApiKey', v)} />
|
||||
<HelpText>Google Maps Places / Directions API キー。未設定時は Nominatim / OSRM(無料)を使用。</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Maps Timeout (秒)</FieldLabel>
|
||||
<FieldInput type="number" value={tools.mapsTimeout ?? ''}
|
||||
onChange={v => onChange('tools.mapsTimeout', v ? Number(v) : undefined)} />
|
||||
<HelpText>Maps / Nominatim / OSRM 呼び出しのタイムアウト(秒)。デフォルト: 30</HelpText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5 pt-2 border-t border-hairline">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { listBrowserSessionProfiles } from '../../api';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
export interface ParamHint {
|
||||
name: string;
|
||||
@@ -56,6 +57,7 @@ export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAs
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [conflictError, setConflictError] = useState<string | null>(null);
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({
|
||||
queryKey: ['browser-session-profiles'],
|
||||
@@ -141,7 +143,7 @@ export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAs
|
||||
/* Backdrop */
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
{...backdrop}
|
||||
>
|
||||
<div className="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
interface Subscription {
|
||||
consumer_user_id: string;
|
||||
@@ -110,11 +111,12 @@ function NoteContentModal({
|
||||
});
|
||||
|
||||
const title = (note.data?.fm.title as string | undefined) || fileName;
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
|
||||
onClick={onClose}
|
||||
{...backdrop}
|
||||
>
|
||||
<div
|
||||
className="bg-surface rounded-md shadow-lg w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden"
|
||||
|
||||
@@ -12,6 +12,7 @@ const SETTINGS_SECTIONS = [
|
||||
'branding',
|
||||
'paths-storage',
|
||||
'execution',
|
||||
'auth',
|
||||
'push-notifications',
|
||||
// LLM group
|
||||
'llm-workers',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Backdrop "click outside to close" that does NOT fire when a drag started
|
||||
* inside the modal (e.g. selecting text in an input) and was released on the
|
||||
* backdrop. A plain `onClick={onClose}` closes in that case because the browser
|
||||
* dispatches `click` on the common ancestor of mousedown+mouseup — which is the
|
||||
* backdrop — losing the user's edits.
|
||||
*
|
||||
* Spread the returned props on the backdrop element and REMOVE the old
|
||||
* `onClick={onClose}`:
|
||||
*
|
||||
* const backdrop = useBackdropClose(onClose);
|
||||
* <div className="fixed inset-0 ..." {...backdrop}> ... </div>
|
||||
*
|
||||
* Close only happens when BOTH the mousedown and the click target are the
|
||||
* backdrop itself, so a genuine backdrop click still closes, but a
|
||||
* press-inside / release-on-backdrop drag does not.
|
||||
*/
|
||||
export function useBackdropClose(onClose: () => void) {
|
||||
const downOnBackdrop = useRef(false);
|
||||
return {
|
||||
onMouseDown: (e: React.MouseEvent) => {
|
||||
downOnBackdrop.current = e.target === e.currentTarget;
|
||||
},
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
if (downOnBackdrop.current && e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
downOnBackdrop.current = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { usePieceList } from '../hooks/usePieces';
|
||||
import { createPiece, fetchPiece, PieceDef, DriftStatus, PieceSummary } from '../api';
|
||||
import { PieceEditor } from '../components/settings/PieceEditor';
|
||||
import { splitPieces } from '../lib/splitPieces';
|
||||
import { useBackdropClose } from '../lib/useBackdropClose';
|
||||
|
||||
type PieceSource = 'builtin' | 'user-custom' | 'global-custom';
|
||||
|
||||
@@ -136,6 +137,7 @@ function PiecesSidebar({
|
||||
const [duplicateName, setDuplicateName] = useState('');
|
||||
const [duplicateError, setDuplicateError] = useState<string | null>(null);
|
||||
const [duplicating, setDuplicating] = useState(false);
|
||||
const duplicateBackdrop = useBackdropClose(() => cancelDuplicate());
|
||||
|
||||
const notifyError = (label: string, err: unknown) => {
|
||||
const msg = `${label}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
@@ -286,7 +288,7 @@ function PiecesSidebar({
|
||||
aria-modal="true"
|
||||
aria-labelledby="dup-piece-label"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 px-4"
|
||||
onClick={cancelDuplicate}
|
||||
{...duplicateBackdrop}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-lg border border-hairline bg-canvas p-4 shadow-xl"
|
||||
|
||||
Reference in New Issue
Block a user