260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { DetailTabId } from '../../lib/urlState';
|
|
import { shareTask, unshareTask } from '../../api';
|
|
|
|
interface Tab { id: DetailTabId; labelKey: string; }
|
|
|
|
interface DetailHeaderProps {
|
|
title: string;
|
|
subtitle: string;
|
|
tabs: Tab[];
|
|
activeTab: DetailTabId;
|
|
/** True while the deferred content tab is still catching up to activeTab.
|
|
* Used to render a subtle pulse on the active tab so the user knows the
|
|
* click was registered even if the content takes a frame or two to paint. */
|
|
tabTransitionPending?: boolean;
|
|
onTabChange: (tab: DetailTabId) => void;
|
|
onClose: () => void;
|
|
detailWidth?: 'normal' | 'focused';
|
|
onWidthToggle?: () => void;
|
|
// 共有機能
|
|
taskId?: number;
|
|
shareToken?: string | null;
|
|
onShareChange?: () => void;
|
|
/** Status of the latest job for this task. The Continue button is shown
|
|
* when latestJobStatus is provided and enabled only on terminal states. */
|
|
latestJobStatus?: string | null;
|
|
/** Click handler for the Continue button. When undefined, the button is
|
|
* hidden entirely (e.g., shared/read-only views). */
|
|
onContinue?: () => void;
|
|
}
|
|
|
|
function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; shareToken: string | null; onShareChange?: () => void }) {
|
|
const { t } = useTranslation('detail');
|
|
const [copied, setCopied] = useState(false);
|
|
const qc = useQueryClient();
|
|
|
|
const shareMutation = useMutation({
|
|
mutationFn: () => shareTask(taskId),
|
|
onSuccess: (data) => {
|
|
const url = `${window.location.origin}${data.shareUrl}`;
|
|
navigator.clipboard.writeText(url);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
|
|
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
|
onShareChange?.();
|
|
},
|
|
});
|
|
|
|
const unshareMutation = useMutation({
|
|
mutationFn: () => unshareTask(taskId),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
|
|
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
|
onShareChange?.();
|
|
},
|
|
});
|
|
|
|
// Refero refresh: collapse share UI from text-button rows into compact
|
|
// icon-only buttons. The title row was getting eaten by long Japanese
|
|
// labels ("リンクコピー" / "共有停止") on narrow viewports; with icons
|
|
// we get the same affordance in ~32px instead of ~80px each.
|
|
const iconBtnBase =
|
|
'inline-flex items-center justify-center w-7 h-7 border rounded-md transition-colors disabled:opacity-50';
|
|
|
|
if (!shareToken) {
|
|
return (
|
|
<button
|
|
onClick={() => shareMutation.mutate()}
|
|
disabled={shareMutation.isPending}
|
|
title={shareMutation.isPending ? t('share.sharing') : t('share.publish')}
|
|
aria-label={t('share.publish')}
|
|
className={`${iconBtnBase} border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface`}
|
|
>
|
|
{shareMutation.isPending ? (
|
|
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="4" cy="8" r="2" />
|
|
<circle cx="12" cy="4" r="2" />
|
|
<circle cx="12" cy="12" r="2" />
|
|
<path d="M5.7 7l4.6-2M5.7 9l4.6 2" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const handleCopy = () => {
|
|
const url = `${window.location.origin}/ui/shared/${shareToken}`;
|
|
navigator.clipboard.writeText(url);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={handleCopy}
|
|
title={copied ? t('share.copied') : t('share.copy')}
|
|
aria-label={t('share.copy')}
|
|
className={`${iconBtnBase} ${copied ? 'border-emerald-200 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300' : 'border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface'}`}
|
|
>
|
|
{copied ? (
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 8.5l3 3 7-7" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
|
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => unshareMutation.mutate()}
|
|
disabled={unshareMutation.isPending}
|
|
title={t('share.stop')}
|
|
aria-label={t('share.stop')}
|
|
className={`${iconBtnBase} border-hairline bg-canvas text-slate-500 hover:text-red-700 dark:hover:text-red-300 hover:border-red-200 hover:bg-red-50 dark:hover:bg-red-500/15`}
|
|
>
|
|
{unshareMutation.isPending ? (
|
|
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M5 5l6 6M11 5l-6 6" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string | null; onClick: () => void }) {
|
|
const { t } = useTranslation('detail');
|
|
// Mirror the spec/backend TERMINAL list (worker maps abort outcomes to
|
|
// 'failed', so 'aborted' is intentionally absent).
|
|
const TERMINAL = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
|
|
const enabled = latestJobStatus != null && TERMINAL.includes(latestJobStatus);
|
|
const iconBtnBase =
|
|
'inline-flex items-center justify-center w-7 h-7 border rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={!enabled}
|
|
title={enabled ? t('continue.label') : t('continue.disabled')}
|
|
aria-label={t('continue.label')}
|
|
className={`${iconBtnBase} border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface`}
|
|
>
|
|
{/* arrow → divider: 「次のフェーズへ進む」cue。FileBrowser の refresh
|
|
(循環矢印) と区別するためフラットな skip-forward 形状を採用 */}
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M2 8h9" />
|
|
<path d="M8 5l3 3-3 3" />
|
|
<path d="M13.5 4v8" />
|
|
</svg>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPending, onTabChange, onClose, detailWidth, onWidthToggle, taskId, shareToken, onShareChange, latestJobStatus, onContinue }: DetailHeaderProps) {
|
|
const { t } = useTranslation('detail');
|
|
// Mobile (< sm) hides the close button and tab bar because App.tsx
|
|
// renders its own mobile-level top tab bar with the same controls.
|
|
// Two close buttons / two tab bars on iPhone was visually redundant.
|
|
return (
|
|
<div className="flex-shrink-0 border-b border-hairline bg-canvas px-4 pt-3 pb-3 sm:pb-0" id="detail-panel-title">
|
|
<div className="flex items-start justify-between gap-2 mb-0 sm:mb-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[10px] font-mono uppercase tracking-wider text-slate-400">{subtitle}</div>
|
|
<div className="font-semibold text-lg text-slate-900 mt-0.5 break-words leading-tight">{title}</div>
|
|
</div>
|
|
{/* Inline action cluster: width toggle + share + close. Share is
|
|
now icon-only (32px) so it fits next to the title instead of
|
|
occupying its own row. */}
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{onContinue && taskId != null && (
|
|
<ContinueButton
|
|
latestJobStatus={latestJobStatus ?? null}
|
|
onClick={onContinue}
|
|
/>
|
|
)}
|
|
{taskId != null && (
|
|
<ShareButton
|
|
taskId={taskId}
|
|
shareToken={shareToken ?? null}
|
|
onShareChange={onShareChange}
|
|
/>
|
|
)}
|
|
{onWidthToggle && detailWidth && (
|
|
<button
|
|
onClick={onWidthToggle}
|
|
title={detailWidth === 'focused' ? t('focus.toStandard') : t('focus.toFocused')}
|
|
aria-label={detailWidth === 'focused' ? t('focus.toStandard') : t('focus.toFocusedShort')}
|
|
aria-pressed={detailWidth === 'focused'}
|
|
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-500 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
|
>
|
|
{detailWidth === 'focused' ? (
|
|
// exit-fullscreen 様: 4 つの内向き角矢印
|
|
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M6 2v4H2M10 2v4h4M6 14v-4H2M10 14v-4h4" />
|
|
</svg>
|
|
) : (
|
|
// enter-fullscreen 様: 4 つの外向き角矢印
|
|
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M2 6V2h4M14 6V2h-4M2 10v4h4M14 10v4h-4" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
aria-label={t('panel.close')}
|
|
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
|
<path d="M4 4l8 8M12 4l-8 8"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex gap-4 -mb-px">
|
|
{tabs.map(tab => {
|
|
const active = activeTab === tab.id;
|
|
const pending = active && tabTransitionPending;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
role="tab"
|
|
aria-selected={active}
|
|
onClick={() => onTabChange(tab.id)}
|
|
className={`pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
|
|
active
|
|
? 'border-accent text-slate-900 font-semibold'
|
|
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
|
|
}`}
|
|
>
|
|
{t(tab.labelKey)}
|
|
{pending && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="inline-block w-2.5 h-2.5 border-2 border-accent border-t-transparent rounded-full animate-spin"
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|