This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSpaces, useUpdateSpace, useArchiveSpace } from '../../hooks/useSpaces';
|
||||
import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces';
|
||||
import { useLocalTaskList } from '../../hooks/useTaskList';
|
||||
import { useLocalTask, useLocalTaskComments } from '../../hooks/useTaskDetail';
|
||||
import { useTaskOperations } from '../../hooks/useTaskOperations';
|
||||
@@ -19,8 +19,10 @@ import type { DetailTabId, SortMode, StatusColumn } from '../../lib/urlState';
|
||||
import { filterTasksForSpace } from '../../lib/spaceTasks';
|
||||
import { filterAndSortTasks, groupTasksByStatus, statusCounts, totalTaskCount } from '../../lib/taskFilter';
|
||||
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
|
||||
import { workspaceDirRole } from '../../lib/workspaceDirs';
|
||||
import { FilterBar } from '../list/FilterBar';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
|
||||
import type { OfficePreviewDescriptor } from '../files/FilePreview';
|
||||
import { CreateTaskDialog } from '../create/CreateTaskDialog';
|
||||
import { LocalTaskListItem } from '../list/TaskListItem';
|
||||
import { ChatPane } from '../chat/ChatPane';
|
||||
@@ -30,13 +32,19 @@ import { SchedulesPage } from '../../pages/SchedulesPage';
|
||||
import { SpaceApps } from './SpaceApps';
|
||||
import { useAuthState } from '../../App';
|
||||
import { SkeletonChatPane } from '../shared/Skeleton';
|
||||
import { EmptyState } from '../shared/EmptyState';
|
||||
import { SwipeableTabs } from '../mobile/SwipeableTabs';
|
||||
import { FilePreview } from '../files/FilePreview';
|
||||
import { FileTileGrid } from '../files/FileTileGrid';
|
||||
import { FileDetailList } from '../files/FileDetailList';
|
||||
import { useFileView } from '../../hooks/useFileView';
|
||||
import { FileBreadcrumb } from '../files/FileBreadcrumb';
|
||||
import { FileActions, FileSelectionBar, FileDropzone } from '../files/FileToolbar';
|
||||
import { MoveTargetDialog } from '../files/MoveTargetDialog';
|
||||
import { resolveMoves } from '../../lib/fileMove';
|
||||
import { FileActions, FileSelectionBar, FileDropzone, FileViewToggle, FileSortMenu, type FileSort } from '../files/FileToolbar';
|
||||
import { filesToBase64 } from '../../lib/fileBase64';
|
||||
import { AppRunner } from './AppRunner';
|
||||
import { createSpaceGateway } from './app-file-gateway';
|
||||
import { detectAppEntry } from './app-bridge';
|
||||
import { ChatDetailSplit } from './ChatDetailSplit';
|
||||
import { OutputPreviewProvider } from '../../lib/output-preview-context';
|
||||
@@ -47,13 +55,27 @@ import {
|
||||
fetchSpaceFiles,
|
||||
fetchSpaceFileContent,
|
||||
getSpaceFileRawUrl,
|
||||
getSpaceFileOfficePreviewUrl,
|
||||
getSpaceTrustedHtmlUrl,
|
||||
uploadSpaceFiles,
|
||||
deleteSpaceFiles,
|
||||
createSpaceFolder,
|
||||
moveSpaceFile,
|
||||
downloadSpaceFilesZip,
|
||||
type CreateLocalTaskInput,
|
||||
type LocalFileEntry,
|
||||
type Space,
|
||||
} from '../../api';
|
||||
import { SpaceFormDialog } from './SpaceFormDialog';
|
||||
|
||||
/** Filter state for the space chat list. Persisted in the URL by App (urlState)
|
||||
* so it survives tab switches, reloads and bookmarks. */
|
||||
export interface SpaceChatFilter {
|
||||
search: string;
|
||||
status: 'all' | StatusColumn;
|
||||
sort: SortMode;
|
||||
scope: TaskScope;
|
||||
}
|
||||
|
||||
interface SpaceDetailProps {
|
||||
spaceId?: string;
|
||||
@@ -62,11 +84,14 @@ interface SpaceDetailProps {
|
||||
onSelectSpaceTask: (id: number) => void;
|
||||
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
onOpenTask: (id: number) => void;
|
||||
chatFilter: SpaceChatFilter;
|
||||
onChatFilterChange: (next: Partial<SpaceChatFilter>) => void;
|
||||
}
|
||||
|
||||
type SpaceTab = 'chat' | 'files' | 'apps' | 'calendar' | 'schedules' | 'settings';
|
||||
|
||||
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpaceDetailProps) {
|
||||
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const { data: spaces } = useSpaces();
|
||||
const [tab, setTab] = useState<SpaceTab>('chat');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -98,8 +123,11 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
|
||||
if (!spaceId || !space) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-slate-500">
|
||||
左の一覧からワークスペースを選んでください。
|
||||
<div className="flex h-full">
|
||||
<EmptyState
|
||||
title={t('detail.empty.title')}
|
||||
hint={t('detail.empty.hint')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,8 +146,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface px-4 py-3`}>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
|
||||
<SpaceHeaderTitle
|
||||
spaceId={spaceId}
|
||||
title={space.title}
|
||||
space={space}
|
||||
canManage={canManage}
|
||||
/>
|
||||
{space.kind === 'case' && (
|
||||
@@ -136,12 +163,12 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
|
||||
{/* Tabs */}
|
||||
<div className={`${hideOnMobileWhenChatOpen} items-center gap-1 border-b border-hairline px-3`}>
|
||||
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>チャット</TabButton>
|
||||
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>ファイル</TabButton>
|
||||
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>アプリ</TabButton>
|
||||
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>カレンダー</TabButton>
|
||||
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>スケジュール</TabButton>
|
||||
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>設定</TabButton>
|
||||
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
|
||||
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
|
||||
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
|
||||
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
|
||||
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
|
||||
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -156,6 +183,8 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
spaceTaskId={spaceTaskId}
|
||||
onSelectSpaceTask={onSelectSpaceTask}
|
||||
onCreateTask={onCreateTask}
|
||||
filter={chatFilter}
|
||||
onFilterChange={onChatFilterChange}
|
||||
/>
|
||||
)}
|
||||
{/* key={spaceId}: ワークスペースを切り替えたら detail のサブツリーを remount し、
|
||||
@@ -164,7 +193,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
ローカル state は remount しないと前のワークスペースの値が残る(空内容で上書き
|
||||
されない AGENTS.md の stale 表示など)。 */}
|
||||
{tab === 'files' && <SpaceFiles key={spaceId} spaceId={spaceId} canManage={canEditFiles} />}
|
||||
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} />}
|
||||
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} canManage={canManage} />}
|
||||
{tab === 'calendar' && (
|
||||
<SpaceCalendar
|
||||
key={spaceId}
|
||||
@@ -181,100 +210,46 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
}
|
||||
|
||||
/**
|
||||
* ヘッダーのワークスペース名。管理権限があればインラインで名前変更できる
|
||||
* (鉛筆ボタン → 入力 → 保存で PATCH → spaces を invalidate)。権限が無ければ
|
||||
* 単なるタイトル表示。
|
||||
* ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が
|
||||
* あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
|
||||
*/
|
||||
function SpaceHeaderTitle({
|
||||
spaceId,
|
||||
title,
|
||||
space,
|
||||
canManage,
|
||||
}: {
|
||||
spaceId: string;
|
||||
title: string;
|
||||
space: Space;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const updateSpace = useUpdateSpace();
|
||||
const { t } = useTranslation('spaces');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 編集を開始したら現在のタイトルを入れ、フォーカスする。
|
||||
const startEdit = useCallback(() => {
|
||||
setValue(title);
|
||||
setEditing(true);
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.select();
|
||||
}, [editing]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const next = value.trim();
|
||||
if (!next || next === title) { setEditing(false); return; }
|
||||
try {
|
||||
await updateSpace.mutateAsync({ id: spaceId, patch: { title: next } });
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// 失敗時は編集状態のまま(入力を消さない)。
|
||||
}
|
||||
}, [value, title, spaceId, updateSpace]);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<h1 className="min-w-0 truncate text-[15px] font-bold text-slate-800">{title}</h1>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-rename"
|
||||
onClick={startEdit}
|
||||
title="ワークスペース名を変更"
|
||||
aria-label="ワークスペース名を変更"
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="space-rename-input"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void save(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); }
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded-md border border-hairline bg-canvas px-2 py-1 text-[15px] font-bold text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-rename-save"
|
||||
onClick={() => void save()}
|
||||
disabled={updateSpace.isPending}
|
||||
title="保存"
|
||||
aria-label="保存"
|
||||
className="inline-flex h-7 items-center rounded-md bg-accent px-2 text-xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
title="キャンセル"
|
||||
aria-label="キャンセル"
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h1 className="shrink truncate text-[15px] font-bold text-slate-800">{space.title}</h1>
|
||||
{space.description && (
|
||||
<span
|
||||
data-testid="space-description"
|
||||
title={space.description}
|
||||
className="hidden min-w-0 shrink truncate text-xs text-slate-400 sm:inline"
|
||||
>
|
||||
{space.description}
|
||||
</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-edit"
|
||||
onClick={() => setEditing(true)}
|
||||
title={t('detail.editSpace')}
|
||||
aria-label={t('detail.editSpace')}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{editing && <SpaceFormDialog space={space} onClose={() => setEditing(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -293,6 +268,7 @@ function SpaceDeleteButton({
|
||||
title: string;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const archiveSpace = useArchiveSpace();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
@@ -309,7 +285,7 @@ function SpaceDeleteButton({
|
||||
if (confirming) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="hidden text-2xs text-slate-500 sm:inline">「{title}」を削除?</span>
|
||||
<span className="hidden text-2xs text-slate-500 sm:inline">{t('detail.deletePrompt', { title })}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-delete-confirm"
|
||||
@@ -317,14 +293,14 @@ function SpaceDeleteButton({
|
||||
disabled={archiveSpace.isPending}
|
||||
className="inline-flex h-7 items-center rounded-md bg-red-600 px-2 text-xs font-bold text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
削除する
|
||||
{t('detail.deleteConfirmButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
|
||||
>
|
||||
キャンセル
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -335,8 +311,8 @@ function SpaceDeleteButton({
|
||||
type="button"
|
||||
data-testid="space-delete"
|
||||
onClick={() => setConfirming(true)}
|
||||
title="ワークスペースを削除"
|
||||
aria-label="ワークスペースを削除"
|
||||
title={t('detail.deleteSpace')}
|
||||
aria-label={t('detail.deleteSpace')}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -352,6 +328,7 @@ function SpaceDeleteButton({
|
||||
* 重なり表示で先頭から最大 4 名、超過は「+N」。クリックで設定タブ(メンバー管理)へ。
|
||||
*/
|
||||
function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage: () => void }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
// キーは SpaceMembersPanel と共有する(招待/ロール変更/除去の invalidate が
|
||||
// ヘッダーのアバター列にも即時反映されるように)。
|
||||
const { data: members } = useQuery({
|
||||
@@ -366,7 +343,7 @@ function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage:
|
||||
const MAX = 4;
|
||||
const shown = members.slice(0, MAX);
|
||||
const overflow = members.length - shown.length;
|
||||
const label = `共有メンバー ${members.length} 名: ${members.map(m => m.name ?? m.userId).join(', ')}`;
|
||||
const label = t('detail.sharedMembers', { count: members.length, names: members.map(m => m.name ?? m.userId).join(', ') });
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -433,25 +410,34 @@ function SpaceChat({
|
||||
isPersonalSpace,
|
||||
spaceTaskId,
|
||||
onSelectSpaceTask,
|
||||
filter,
|
||||
onFilterChange,
|
||||
}: {
|
||||
spaceId: string;
|
||||
isPersonalSpace: boolean;
|
||||
spaceTaskId?: number;
|
||||
onSelectSpaceTask: (id: number) => void;
|
||||
onCreateTask: SpaceDetailProps['onCreateTask'];
|
||||
filter: SpaceChatFilter;
|
||||
onFilterChange: (next: Partial<SpaceChatFilter>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const auth = useAuthState();
|
||||
const { data: allTasks } = useLocalTaskList();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [scope, setScope] = useState<TaskScope>('mine');
|
||||
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
|
||||
const setScope = (val: TaskScope) => onFilterChange({ scope: val });
|
||||
const setSearchQuery = (val: string) => onFilterChange({ search: val });
|
||||
const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val });
|
||||
const setSortMode = (val: SortMode) => onFilterChange({ sort: val });
|
||||
const spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace);
|
||||
|
||||
// 自分/他メンバーの切替。共有ワークスペースで「他の人のタスク」が存在するときだけ
|
||||
// 出す(個人ワークスペースや単独利用では意味がないので隠す)。スコープ分割は Tasks
|
||||
// ページと同じ filterTasksByScope を再利用する(owner_id null は others 側、という
|
||||
// 既存規約に合わせる)。SpaceChat は key={spaceId} で remount されるので、スペースを
|
||||
// 切り替えると scope は 'mine' に戻る。
|
||||
// 既存規約に合わせる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の
|
||||
// onSelectSpace が search/status/sort/scope を明示リセットする(remount 依存ではない)。
|
||||
const userId = auth.mode === 'authenticated' ? auth.user.id : null;
|
||||
const hasOthersTasks = userId != null && spaceTasks.some(t => t.ownerId !== userId);
|
||||
const tasks = userId != null && hasOthersTasks
|
||||
@@ -459,10 +445,8 @@ function SpaceChat({
|
||||
: spaceTasks;
|
||||
|
||||
// 検索・ステータス・ソート(Tasks ページの FilterBar と同じ挙動を共有ヘルパーで再現)。
|
||||
// scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<'all' | StatusColumn>('all');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('updated');
|
||||
// scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。state は
|
||||
// URL 永続化のため SpaceDetail(親)→ App の urlState から流す。
|
||||
const statusColumns = groupTasksByStatus(tasks);
|
||||
const counts = statusCounts(statusColumns);
|
||||
const totalCount = totalTaskCount(statusColumns);
|
||||
@@ -490,14 +474,14 @@ function SpaceChat({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-hairline">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">このワークスペースのチャット</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">{t('chat.listHeading')}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-new-chat-btn"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="shrink-0 rounded-md bg-accent px-2.5 py-1.5 text-xs font-bold text-accent-fg transition-colors hover:opacity-90"
|
||||
>
|
||||
+ 新規
|
||||
{t('chat.new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -506,7 +490,7 @@ function SpaceChat({
|
||||
data-testid="space-chat-scope-toggle"
|
||||
className="flex items-center gap-1 border-b border-hairline px-3 py-1.5"
|
||||
>
|
||||
{([['mine', '自分'], ['others', '他のメンバー']] as const).map(([val, label]) => (
|
||||
{(['mine', 'others'] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
@@ -519,7 +503,7 @@ function SpaceChat({
|
||||
: 'text-slate-500 hover:bg-surface hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{t(`chat.scope.${val}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -542,14 +526,21 @@ function SpaceChat({
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
{totalCount === 0 ? (
|
||||
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||||
{hasOthersTasks && scope === 'others'
|
||||
? '他のメンバーが作成したチャットはありません。'
|
||||
: 'このワークスペースにはまだチャットがありません。「+ 新規」で始めましょう。'}
|
||||
</p>
|
||||
hasOthersTasks && scope === 'others' ? (
|
||||
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||||
{t('chat.empty.others')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||||
<p>{t('chat.empty.none')}</p>
|
||||
<p className="mt-1.5 text-2xs text-slate-400 leading-relaxed">
|
||||
{t('chat.empty.filesHint')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : visibleTasks.length === 0 ? (
|
||||
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||||
条件に一致するチャットがありません。
|
||||
{t('chat.empty.noMatch')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -580,7 +571,7 @@ function SpaceChat({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
|
||||
左の一覧からチャットを選ぶか、「+ 新規」で始めてください。
|
||||
{t('chat.selectHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -602,6 +593,7 @@ function SpaceChat({
|
||||
// スペース内インライン会話。App の Tasks 詳細と同じ hook / handler を使い、挙動
|
||||
// (追加指示送信・キャンセル・ライブ表示)を完全一致させる。
|
||||
function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => void }) {
|
||||
const { t: ts } = useTranslation('spaces');
|
||||
const { toast, showToast } = useToast();
|
||||
const taskQuery = useLocalTask(taskId, true);
|
||||
const commentsQuery = useLocalTaskComments(taskId, true);
|
||||
@@ -637,7 +629,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
|
||||
// 削除(確認ダイアログ付き)。confirm をキャンセルしたら何もしない。
|
||||
const confirmAndDelete = useCallback(async () => {
|
||||
if (!window.confirm('このチャットを削除しますか?この操作は取り消せません。')) return;
|
||||
if (!window.confirm(ts('conversation.deleteConfirm'))) return;
|
||||
await handleDelete();
|
||||
}, [handleDelete]);
|
||||
|
||||
@@ -788,7 +780,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 4l-4 4 4 4" />
|
||||
</svg>
|
||||
一覧へ
|
||||
{ts('conversation.backToList')}
|
||||
</button>
|
||||
|
||||
{/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と
|
||||
@@ -804,10 +796,10 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */}
|
||||
<span
|
||||
data-testid="space-chat-visibility-note"
|
||||
title="このワークスペースのメンバーだけが閲覧できます"
|
||||
title={ts('conversation.visibilityTitle')}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-500"
|
||||
>
|
||||
🔒 このワークスペースのメンバーに公開
|
||||
{ts('conversation.visibilityNote')}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
@@ -826,8 +818,8 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
type="button"
|
||||
data-testid="space-chat-delete"
|
||||
onClick={() => void confirmAndDelete()}
|
||||
title="削除"
|
||||
aria-label="削除"
|
||||
title={ts('common:delete')}
|
||||
aria-label={ts('common:delete')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -930,6 +922,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
section={previewState.section}
|
||||
filePath={previewState.filePath}
|
||||
editable={previewState.editable}
|
||||
office={previewState.office}
|
||||
/>
|
||||
)}
|
||||
{toast && (
|
||||
@@ -953,6 +946,7 @@ interface SpacePreviewState {
|
||||
imageSrc: string;
|
||||
markdownImageBaseUrl?: string;
|
||||
trustedHtmlUrl?: string;
|
||||
office?: OfficePreviewDescriptor;
|
||||
}
|
||||
|
||||
// ソースライブラリの curated 一覧 UI は撤去した(#6)。エージェントが取得した資料は
|
||||
@@ -964,6 +958,7 @@ interface SpacePreviewState {
|
||||
// 自分のワークスペースなので削除可)。共有ワークスペースでは SpaceDetail が
|
||||
// owner/admin 判定を渡す。サーバ側も canEditInSpace で再度ゲートする。
|
||||
export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [entries, setEntries] = useState<LocalFileEntry[]>([]);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
@@ -977,6 +972,9 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
// 「移動」ダイアログで移動する対象(null = 閉じている)。
|
||||
const [moveDialogSources, setMoveDialogSources] = useState<string[] | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -986,7 +984,7 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
setLoadError('');
|
||||
} catch {
|
||||
setEntries([]);
|
||||
setLoadError('ファイルの取得に失敗しました');
|
||||
setLoadError(t('files.loadError'));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
@@ -999,6 +997,16 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
|
||||
const handlePreview = useCallback(async (filePath: string, name: string) => {
|
||||
try {
|
||||
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
|
||||
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
|
||||
setPreview({
|
||||
name,
|
||||
content: '',
|
||||
imageSrc: '',
|
||||
office: { kind, url: getSpaceFileOfficePreviewUrl(spaceId, filePath), downloadUrl: getSpaceFileRawUrl(spaceId, filePath) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
|
||||
const imageSrc = getSpaceFileRawUrl(spaceId, filePath);
|
||||
const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined;
|
||||
@@ -1013,7 +1021,7 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
}
|
||||
setPreview({ name, content, imageSrc: '', markdownImageBaseUrl });
|
||||
} catch {
|
||||
setLoadError('ファイルの読み込みに失敗しました');
|
||||
setLoadError(t('files.previewError'));
|
||||
}
|
||||
}, [spaceId]);
|
||||
|
||||
@@ -1025,14 +1033,88 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
const payload = await filesToBase64(fileList);
|
||||
const r = await uploadSpaceFiles(spaceId, currentPath, payload);
|
||||
await load();
|
||||
setUploadMsg({ text: `${r.uploaded.length} 件のファイルを追加しました`, kind: 'ok' });
|
||||
setUploadMsg({ text: t('files.uploadedCount', { count: r.uploaded.length }), kind: 'ok' });
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: `アップロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
|
||||
setUploadMsg({ text: t('files.uploadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [spaceId, currentPath, load]);
|
||||
|
||||
// 現在フォルダに空フォルダを作る。既存スペースに readonly/ を後付けする用途も兼ねる。
|
||||
const createFolder = useCallback(async () => {
|
||||
const name = window.prompt(t('files.newFolderPrompt'))?.trim();
|
||||
if (!name) return;
|
||||
if (/[\\/]/.test(name)) {
|
||||
setUploadMsg({ text: t('files.folderNameInvalid'), kind: 'error' });
|
||||
return;
|
||||
}
|
||||
const rel = currentPath ? `${currentPath}/${name}` : name;
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
await createSpaceFolder(spaceId, rel);
|
||||
await load();
|
||||
setUploadMsg({ text: t('files.folderCreated', { name }), kind: 'ok' });
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: t('files.folderCreateFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
}
|
||||
}, [spaceId, currentPath, load]);
|
||||
|
||||
// ファイル/フォルダのリネーム(move エンドポイント経由)。構造ディレクトリ
|
||||
// (input/output/logs/apps/readonly)はサーバ側で拒否され、UI でも行アクションを出さない。
|
||||
const renameEntry = useCallback(async (entry: LocalFileEntry) => {
|
||||
const next = window.prompt(t('files.renamePrompt'), entry.name)?.trim();
|
||||
if (!next || next === entry.name) return;
|
||||
if (/[\\/]/.test(next)) {
|
||||
setUploadMsg({ text: t('files.nameInvalid'), kind: 'error' });
|
||||
return;
|
||||
}
|
||||
const slash = entry.path.lastIndexOf('/');
|
||||
const parent = slash >= 0 ? entry.path.slice(0, slash) : '';
|
||||
const to = parent ? `${parent}/${next}` : next;
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const r = await moveSpaceFile(spaceId, entry.path, to);
|
||||
await load();
|
||||
setUploadMsg({ text: t('files.renamed', { from: entry.name, to: r.to.split('/').pop() }), kind: 'ok' });
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: t('files.renameFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
}
|
||||
}, [spaceId, load]);
|
||||
|
||||
// ファイル/フォルダをフォルダへ移動する(ドラッグ移動・複数選択移動の共通経路)。
|
||||
// resolveMoves が no-op / 自己内移動を事前除外し、残りを move エンドポイントへ順に投げる。
|
||||
// 衝突はサーバが自動リネームするため、ここでは件数だけ報告する。
|
||||
const moveInto = useCallback(async (sourcePaths: string[], destDir: string) => {
|
||||
const { moves, skipped } = resolveMoves(sourcePaths, destDir);
|
||||
if (moves.length === 0) {
|
||||
if (skipped.length > 0) setUploadMsg({ text: t('files.alreadyThere'), kind: 'ok' });
|
||||
return;
|
||||
}
|
||||
setIsMoving(true);
|
||||
setUploadMsg(null);
|
||||
let moved = 0;
|
||||
let failed = 0;
|
||||
for (const m of moves) {
|
||||
try {
|
||||
await moveSpaceFile(spaceId, m.from, m.to);
|
||||
moved++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
const where = destDir ? t('files.moveTargetFolder', { name: destDir.split('/').pop() }) : t('files.moveTargetRoot');
|
||||
setUploadMsg(
|
||||
failed > 0
|
||||
// 一部でも失敗したら赤で示す(成功緑だと失敗を見落とすため)。
|
||||
? { text: t('files.movedWithFailures', { moved, where, failed }), kind: 'error' }
|
||||
: { text: t('files.moved', { moved, where }), kind: 'ok' },
|
||||
);
|
||||
setIsMoving(false);
|
||||
}, [spaceId, load]);
|
||||
|
||||
// フォルダ移動・ワークスペース切替で選択をクリア(別ディレクトリのパスを持ち越さない)。
|
||||
useEffect(() => { setSelected(new Set()); }, [currentPath, spaceId]);
|
||||
|
||||
@@ -1048,16 +1130,16 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
// ガード + canEditInSpace で再ゲートするが、UI は相対パスだけを送る。
|
||||
const deleteSelected = useCallback(async (paths: string[]) => {
|
||||
if (paths.length === 0) return;
|
||||
if (!window.confirm(`${paths.length} 件のファイルを削除しますか?この操作は取り消せません。`)) return;
|
||||
if (!window.confirm(t('files.deleteConfirm', { count: paths.length }))) return;
|
||||
setIsDeleting(true);
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const r = await deleteSpaceFiles(spaceId, paths);
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
setUploadMsg({ text: `${r.deleted.length} 件のファイルを削除しました`, kind: 'ok' });
|
||||
setUploadMsg({ text: t('files.deletedCount', { count: r.deleted.length }), kind: 'ok' });
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: `削除に失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
|
||||
setUploadMsg({ text: t('files.deleteFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -1071,46 +1153,98 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
try {
|
||||
await downloadSpaceFilesZip(spaceId, paths);
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: `ダウンロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
|
||||
setUploadMsg({ text: t('files.downloadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [spaceId]);
|
||||
|
||||
const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : [];
|
||||
const dirs = entries.filter(e => e.kind === 'directory');
|
||||
// source/index.jsonl は「ソース」グループのデータ源なので、生のファイル一覧には
|
||||
// 出さない(ノイズ回避)。source/ フォルダ自体は通常どおりブラウズできる。
|
||||
const files = entries.filter(
|
||||
e => e.kind !== 'directory' && !(currentPath === 'source' && e.name === 'index.jsonl'),
|
||||
const visibleEntries = entries.filter(
|
||||
e => !(e.kind !== 'directory' && currentPath === 'source' && e.name === 'index.jsonl'),
|
||||
);
|
||||
const sorted = [
|
||||
...dirs.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
...files.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
const { viewMode, setViewMode, sort, setSort, toggleSort, sortedEntries } = useFileView(visibleEntries);
|
||||
// アイコン表示のドロップダウン(名前順/新しい順)は共有ソート状態に縮約して乗せる。
|
||||
// サイズ順は詳細表示の列見出しから操作する(タスク窓の FileBrowser と同方針)。
|
||||
const menuSort: FileSort = sort.key === 'modified' ? 'newest' : 'name';
|
||||
const onMenuSort = (s: FileSort) =>
|
||||
setSort(s === 'newest' ? { key: 'modified', dir: 'desc' } : { key: 'name', dir: 'asc' });
|
||||
|
||||
// 選択可能なのはファイルのみ(ディレクトリは削除対象外)。
|
||||
const selectablePaths = files.map(f => f.path);
|
||||
// 選択可能なのはファイル+ユーザー作成フォルダ(構造フォルダ=workspaceDirRole 有 は保護で除外)。
|
||||
const selectablePaths = visibleEntries
|
||||
.filter(e => e.kind !== 'directory' || workspaceDirRole(e.path, e.name, e.kind) == null)
|
||||
.map(f => f.path);
|
||||
const allSelected = selectablePaths.length > 0 && selectablePaths.every(p => selected.has(p));
|
||||
const selectedInView = selectablePaths.filter(p => selected.has(p));
|
||||
|
||||
// リネームボタン(詳細表示・アイコン表示で共有)。構造ディレクトリ
|
||||
// (input/output/logs/apps/readonly)はサーバ側で拒否されるため出さない。
|
||||
const renameButton = (entry: LocalFileEntry) => {
|
||||
const isStructural = workspaceDirRole(entry.path, entry.name, entry.kind) != null;
|
||||
if (!canManage || isStructural) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-file-rename-${entry.name}`}
|
||||
onClick={() => void renameEntry(entry)}
|
||||
title={t('files.rename')}
|
||||
aria-label={t('files.renameAria', { name: entry.name })}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M10.5 2.5l3 3L6 13l-3.5.5L3 10z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="space-files" className="flex flex-col gap-3">
|
||||
{/* パンくず(現在地)+ 操作。パスはパンくずのみで表す(テキスト二重表示を廃止)。 */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 pt-1 font-mono text-2xs text-slate-500 break-all">
|
||||
/files{currentPath ? `/${currentPath}` : ''}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<FileBreadcrumb
|
||||
testid="space-files-breadcrumb"
|
||||
pathSegments={pathSegments}
|
||||
onNavigate={setCurrentPath}
|
||||
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{viewMode === 'icon' && <FileSortMenu sort={menuSort} onChange={onMenuSort} />}
|
||||
<FileViewToggle idPrefix="space" mode={viewMode} onChange={setViewMode} />
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-files-mkdir-btn"
|
||||
onClick={() => void createFolder()}
|
||||
className="inline-flex items-center gap-1 px-2 h-7 rounded text-2xs font-medium border border-hairline bg-canvas text-slate-600 hover:bg-surface transition-colors"
|
||||
title={t('files.mkdirTitle')}
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h2.6l1.2 1.6h5.2A1.5 1.5 0 0116 6.1V12a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 012 12V4.5zM8 7.5v4M6 9.5h4" />
|
||||
</svg>
|
||||
{t('files.newFolder')}
|
||||
</button>
|
||||
)}
|
||||
<FileActions
|
||||
idPrefix="space"
|
||||
canUpload={canManage}
|
||||
onUploadFiles={files => void uploadFiles(files)}
|
||||
onRefresh={() => void load()}
|
||||
isRefreshing={isRefreshing}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
</div>
|
||||
<FileActions
|
||||
idPrefix="space"
|
||||
canUpload={canManage}
|
||||
onUploadFiles={files => void uploadFiles(files)}
|
||||
onRefresh={() => void load()}
|
||||
isRefreshing={isRefreshing}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FileBreadcrumb testid="space-files-breadcrumb" pathSegments={pathSegments} onNavigate={setCurrentPath} />
|
||||
{!currentPath && !loadError && selectablePaths.length === 0 && (
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||||
{t('files.inputHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canManage && selectablePaths.length > 0 && (
|
||||
<FileSelectionBar
|
||||
@@ -1120,8 +1254,10 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
selectedCount={selectedInView.length}
|
||||
onDeleteSelected={() => void deleteSelected(selectedInView)}
|
||||
onDownloadSelected={() => void downloadSelected(selectedInView)}
|
||||
onMoveSelected={() => setMoveDialogSources(selectedInView)}
|
||||
isDeleting={isDeleting}
|
||||
isDownloading={isDownloading}
|
||||
isMoving={isMoving}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1135,39 +1271,87 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
enabled={canManage}
|
||||
isUploading={isUploading}
|
||||
onDropFiles={files => void uploadFiles(files)}
|
||||
onRejectFolder={() => setUploadMsg({ text: 'フォルダは未対応です。ファイルを選んでください。', kind: 'error' })}
|
||||
onRejectFolder={() => setUploadMsg({ text: t('files.folderNotSupported'), kind: 'error' })}
|
||||
>
|
||||
<FileTileGrid
|
||||
entries={sorted}
|
||||
idPrefix="space"
|
||||
canManage={canManage}
|
||||
selected={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenDir={setCurrentPath}
|
||||
onOpenFile={(path, name) => void handlePreview(path, name)}
|
||||
onDeleteOne={path => void deleteSelected([path])}
|
||||
isDeleting={isDeleting}
|
||||
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
|
||||
renderTileOverlay={entry => {
|
||||
// apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。
|
||||
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
|
||||
if (!appEntry) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-run-${appEntry.appName}`}
|
||||
onClick={() => setAppToRun(appEntry)}
|
||||
title="アプリとして実行"
|
||||
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
アプリとして実行
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
emptyHint={loadError ? null : (canManage
|
||||
? 'ファイルがありません。ここにドラッグ&ドロップ、または「+ 追加」で追加できます。'
|
||||
: 'ファイルがありません。')}
|
||||
/>
|
||||
{viewMode === 'detail' ? (
|
||||
<FileDetailList
|
||||
entries={sortedEntries}
|
||||
idPrefix="space"
|
||||
canManage={canManage}
|
||||
selected={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenDir={setCurrentPath}
|
||||
onOpenFile={(path, name) => void handlePreview(path, name)}
|
||||
onDeleteOne={path => void deleteSelected([path])}
|
||||
isDeleting={isDeleting}
|
||||
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
|
||||
onDownloadDir={path => void downloadSelected([path])}
|
||||
sort={sort}
|
||||
onSort={toggleSort}
|
||||
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
|
||||
renderRowAction={entry => {
|
||||
// 詳細表示でも apps/{name}/index.html は「実行」で起動できるようにする
|
||||
// (アイコン表示の renderTileOverlay と対)。
|
||||
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
|
||||
const rename = renameButton(entry);
|
||||
if (!appEntry && !rename) return null;
|
||||
return (
|
||||
<>
|
||||
{rename}
|
||||
{appEntry && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-run-${appEntry.appName}`}
|
||||
onClick={() => setAppToRun(appEntry)}
|
||||
title={t('files.runAsApp')}
|
||||
className="inline-flex h-5 items-center rounded bg-[var(--brand-primary)] px-1.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-90 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
{t('files.run')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
emptyHint={loadError ? null : (canManage
|
||||
? t('files.emptyManage')
|
||||
: t('files.empty'))}
|
||||
/>
|
||||
) : (
|
||||
<FileTileGrid
|
||||
entries={sortedEntries}
|
||||
idPrefix="space"
|
||||
canManage={canManage}
|
||||
selected={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenDir={setCurrentPath}
|
||||
onOpenFile={(path, name) => void handlePreview(path, name)}
|
||||
onDeleteOne={path => void deleteSelected([path])}
|
||||
isDeleting={isDeleting}
|
||||
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
|
||||
onDownloadDir={path => void downloadSelected([path])}
|
||||
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
|
||||
renderEntryAction={renameButton}
|
||||
renderTileOverlay={entry => {
|
||||
// apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。
|
||||
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
|
||||
if (!appEntry) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-run-${appEntry.appName}`}
|
||||
onClick={() => setAppToRun(appEntry)}
|
||||
title={t('files.runAsApp')}
|
||||
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
{t('files.runAsApp')}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
emptyHint={loadError ? null : (canManage
|
||||
? t('files.emptyManage')
|
||||
: t('files.empty'))}
|
||||
/>
|
||||
)}
|
||||
</FileDropzone>
|
||||
|
||||
{preview && (
|
||||
@@ -1177,18 +1361,32 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
imageSrc={preview.imageSrc}
|
||||
markdownImageBaseUrl={preview.markdownImageBaseUrl}
|
||||
trustedHtmlUrl={preview.trustedHtmlUrl}
|
||||
office={preview.office}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{appToRun && (
|
||||
<AppRunner
|
||||
spaceId={spaceId}
|
||||
gateway={createSpaceGateway(spaceId)}
|
||||
appName={appToRun.appName}
|
||||
entryPath={appToRun.entryPath}
|
||||
onClose={() => setAppToRun(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{moveDialogSources && (
|
||||
<MoveTargetDialog
|
||||
spaceId={spaceId}
|
||||
sourcePaths={moveDialogSources}
|
||||
isMoving={isMoving}
|
||||
onClose={() => setMoveDialogSources(null)}
|
||||
onConfirm={dest => {
|
||||
setMoveDialogSources(null);
|
||||
void moveInto(moveDialogSources, dest);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user