This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Pure-function tests for shouldConfirmWrite.
|
||||
* No DOM render needed — these run in node env.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shouldConfirmWrite } from './app-bridge';
|
||||
|
||||
describe('shouldConfirmWrite', () => {
|
||||
it('returns false when autoApprove=true (always bypass confirm)', () => {
|
||||
// Even a path that would normally require confirm is bypassed.
|
||||
expect(shouldConfirmWrite('my-app', 'some/arbitrary/file.txt', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when path is under output/ (allowlisted, no confirm needed)', () => {
|
||||
expect(shouldConfirmWrite('my-app', 'output/report.csv', false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when autoApprove=false and path is NOT allowlisted', () => {
|
||||
// A path not under output/ or apps/{appName}/data/ must trigger a confirm.
|
||||
expect(shouldConfirmWrite('my-app', 'some/arbitrary/file.txt', false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fetchSpaceFileContent, fetchSpaceFiles, writeSpaceFile, deleteSpaceFiles, getSpaceFileRawUrl } from '../../api';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import type { AppFileGateway } from './app-file-gateway';
|
||||
import {
|
||||
resolveAppPath,
|
||||
isWriteWithoutConfirm,
|
||||
shouldConfirmWrite,
|
||||
isAppBridgeRequest,
|
||||
type AppBridgeResponse,
|
||||
} from './app-bridge';
|
||||
@@ -34,12 +35,26 @@ import {
|
||||
*/
|
||||
|
||||
interface AppRunnerProps {
|
||||
spaceId: string;
|
||||
/**
|
||||
* File I/O gateway. The authenticated space gateway exposes writeFile/deleteFile;
|
||||
* the public (read-only) app-share gateway omits them. AppRunner keys the
|
||||
* write/delete bridge paths off their presence — no separate read-only flag needed
|
||||
* for I/O. `canWrite` only affects the header copy + whether write confirms are even
|
||||
* attempted (defaults to true when a writeFile is present).
|
||||
*/
|
||||
gateway: AppFileGateway;
|
||||
/** App folder name under apps/ (e.g. "invoice-gen" for apps/invoice-gen/index.html). */
|
||||
appName: string;
|
||||
/** Workspace-relative path to the app's entry HTML (e.g. "apps/foo/index.html"). */
|
||||
entryPath: string;
|
||||
onClose: () => void;
|
||||
/** Explicit read/write hint for the header copy. Defaults to gateway.writeFile presence. */
|
||||
canWrite?: boolean;
|
||||
/**
|
||||
* When true, skip all write/delete confirm dialogs (headless E2E harness only).
|
||||
* In production this prop is omitted (defaults to false), so behavior is unchanged.
|
||||
*/
|
||||
autoApproveWrites?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
@@ -78,7 +93,7 @@ function injectCsp(html: string): string {
|
||||
return `${meta}${html}`;
|
||||
}
|
||||
|
||||
function rewriteRelativeAssets(html: string, spaceId: string, appName: string): string {
|
||||
function rewriteRelativeAssets(html: string, appName: string, rawUrl: (path: string) => string): string {
|
||||
const appDir = `apps/${appName}`;
|
||||
return html.replace(/\b(src|href)\s*=\s*(["'])(.*?)\2/gi, (m, attr: string, q: string, url: string) => {
|
||||
const u = url.trim();
|
||||
@@ -98,32 +113,37 @@ function rewriteRelativeAssets(html: string, spaceId: string, appName: string):
|
||||
} catch {
|
||||
return m; // unsafe relative → leave untouched (will simply fail to load)
|
||||
}
|
||||
return `${attr}=${q}${getSpaceFileRawUrl(spaceId, rel)}${q}`;
|
||||
return `${attr}=${q}${rawUrl(rel)}${q}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerProps) {
|
||||
export function AppRunner({ gateway, appName, entryPath, onClose, canWrite, autoApproveWrites = false }: AppRunnerProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [srcDoc, setSrcDoc] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
|
||||
|
||||
// Fetch + prepare the app HTML once per (spaceId, entryPath).
|
||||
// 書き込み可否は gateway.writeFile の有無で決まる(read-only gateway は未定義)。
|
||||
// canWrite は header コピーの上書きヒントのみ(既定は writeFile の存在)。
|
||||
const writable = canWrite ?? typeof gateway.writeFile === 'function';
|
||||
|
||||
// Fetch + prepare the app HTML once per (gateway, entryPath).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSrcDoc(null);
|
||||
setLoadError('');
|
||||
(async () => {
|
||||
try {
|
||||
const html = await fetchSpaceFileContent(spaceId, entryPath);
|
||||
const html = await gateway.fetchContent(entryPath);
|
||||
if (cancelled) return;
|
||||
setSrcDoc(injectCsp(rewriteRelativeAssets(html, spaceId, appName)));
|
||||
setSrcDoc(injectCsp(rewriteRelativeAssets(html, appName, gateway.rawUrl)));
|
||||
} catch {
|
||||
if (!cancelled) setLoadError('アプリの読み込みに失敗しました');
|
||||
if (!cancelled) setLoadError(t('appRunner.loadError'));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [spaceId, entryPath, appName]);
|
||||
}, [gateway, entryPath, appName]);
|
||||
|
||||
// Ask the user before a non-allowlisted write. Returns a promise that
|
||||
// resolves true (approved) / false (denied).
|
||||
@@ -147,7 +167,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
switch (req.type) {
|
||||
case 'readFile': {
|
||||
const path = resolveAppPath(appName, req.path);
|
||||
const content = await fetchSpaceFileContent(spaceId, path);
|
||||
const content = await gateway.fetchContent(path);
|
||||
return { id, ok: true, data: { path, content } };
|
||||
}
|
||||
case 'listFiles': {
|
||||
@@ -157,27 +177,33 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
if (typeof dirRaw === 'string' && dirRaw.trim() !== '') {
|
||||
dir = resolveAppPath(appName, dirRaw);
|
||||
}
|
||||
const r = await fetchSpaceFiles(spaceId, dir);
|
||||
const r = await gateway.listFiles(dir);
|
||||
return { id, ok: true, data: { dir, entries: r.entries } };
|
||||
}
|
||||
case 'writeFile': {
|
||||
// read-only gateway(公開共有)には writeFile が無い → 書き込み不可。
|
||||
if (!gateway.writeFile) return { id, ok: false, error: 'read-only' };
|
||||
const path = resolveAppPath(appName, req.path);
|
||||
if (typeof req.content !== 'string') {
|
||||
return { id, ok: false, error: 'content must be a string' };
|
||||
}
|
||||
if (!isWriteWithoutConfirm(appName, path)) {
|
||||
if (shouldConfirmWrite(appName, path, autoApproveWrites)) {
|
||||
const approved = await requestWriteConfirm(path);
|
||||
if (!approved) return { id, ok: false, error: 'write denied by user' };
|
||||
}
|
||||
const res = await writeSpaceFile(spaceId, path, { content: req.content });
|
||||
const res = await gateway.writeFile(path, req.content);
|
||||
return { id, ok: true, data: res };
|
||||
}
|
||||
case 'deleteFile': {
|
||||
// read-only gateway(公開共有)には deleteFile が無い → 削除不可。
|
||||
if (!gateway.deleteFile) return { id, ok: false, error: 'read-only' };
|
||||
const path = resolveAppPath(appName, req.path);
|
||||
// delete always confirms, regardless of directory.
|
||||
const approved = await requestWriteConfirm(path);
|
||||
if (!approved) return { id, ok: false, error: 'delete denied by user' };
|
||||
const res = await deleteSpaceFiles(spaceId, [path]);
|
||||
// delete always confirms, unless autoApproveWrites bypasses it.
|
||||
if (!autoApproveWrites) {
|
||||
const approved = await requestWriteConfirm(path);
|
||||
if (!approved) return { id, ok: false, error: 'delete denied by user' };
|
||||
}
|
||||
const res = await gateway.deleteFile(path);
|
||||
return { id, ok: true, data: res };
|
||||
}
|
||||
default:
|
||||
@@ -186,7 +212,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
} catch (e) {
|
||||
return { id, ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}, [spaceId, appName, requestWriteConfirm]);
|
||||
}, [gateway, appName, requestWriteConfirm, autoApproveWrites]);
|
||||
|
||||
// postMessage listener — only trusts messages from OUR iframe's window.
|
||||
useEffect(() => {
|
||||
@@ -214,15 +240,21 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
data-testid="app-runner"
|
||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-label={`ワークスペース・アプリ ${title}`}
|
||||
aria-label={t('appRunner.dialogLabel', { title })}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-hairline px-4 py-2">
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-100">{title}</span>
|
||||
<span
|
||||
className="rounded bg-surface px-2 py-0.5 text-2xs text-slate-500"
|
||||
title="このアプリはワークスペースのファイルにアクセスします(あなたの権限の範囲内)"
|
||||
title={
|
||||
writable
|
||||
? t('appRunner.accessTitle.writable')
|
||||
: t('appRunner.accessTitle.readonly')
|
||||
}
|
||||
>
|
||||
このアプリはワークスペースのファイルにアクセスします
|
||||
{writable
|
||||
? t('appRunner.accessLabel.writable')
|
||||
: t('appRunner.accessLabel.readonly')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
@@ -231,7 +263,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
onClick={onClose}
|
||||
className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
>
|
||||
閉じる
|
||||
{t('appRunner.close')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -258,10 +290,15 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
data-testid="app-write-confirm"
|
||||
className="w-[min(28rem,90vw)] rounded-lg border border-hairline bg-canvas p-4 shadow-xl"
|
||||
role="alertdialog"
|
||||
aria-label="書き込み確認"
|
||||
aria-label={t('appRunner.writeConfirm.label')}
|
||||
>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-200">
|
||||
アプリ「{title}」が <code className="rounded bg-surface px-1">{confirm.path}</code> に書き込もうとしています。許可しますか?
|
||||
<Trans
|
||||
i18nKey="appRunner.writeConfirm.body"
|
||||
t={t}
|
||||
values={{ title, path: confirm.path }}
|
||||
components={{ path: <code className="rounded bg-surface px-1" /> }}
|
||||
/>
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
@@ -270,7 +307,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
onClick={() => handleConfirm(false)}
|
||||
className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface"
|
||||
>
|
||||
拒否
|
||||
{t('appRunner.writeConfirm.deny')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -278,7 +315,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
onClick={() => handleConfirm(true)}
|
||||
className="rounded bg-[var(--brand-primary)] px-3 py-1 text-sm text-white hover:opacity-90"
|
||||
>
|
||||
許可
|
||||
{t('appRunner.writeConfirm.allow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* 本コンポーネントはワークスペース統合(タスクページ廃止に向けた操作感寄せ)の一部。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SPLIT_MIN_LEFT = 320; // チャットの最小幅(px)
|
||||
export const SPLIT_MIN_RIGHT = 360; // 詳細の最小幅(px)
|
||||
@@ -67,6 +68,7 @@ interface ChatDetailSplitProps {
|
||||
}
|
||||
|
||||
export function ChatDetailSplit({ left, right, rightVisible, storageKey }: ChatDetailSplitProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const leftPaneRef = useRef<HTMLDivElement>(null);
|
||||
const draggingRef = useRef(false);
|
||||
@@ -172,7 +174,7 @@ export function ChatDetailSplit({ left, right, rightVisible, storageKey }: ChatD
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="チャットと詳細の幅を調整"
|
||||
aria-label={t('chatDetailSplit.resizeLabel')}
|
||||
data-testid="chat-detail-resize"
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={handleReset}
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchCrossSpaceCalendarMonth,
|
||||
fetchSpaceCalendarDay,
|
||||
type CrossCalendarSpace,
|
||||
type CalendarEvent,
|
||||
} from '../../api';
|
||||
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
|
||||
import { localToday, localTzOffset } from '../../lib/localDate';
|
||||
import {
|
||||
WEEKDAYS,
|
||||
localMonth,
|
||||
shiftMonth,
|
||||
monthGridDays,
|
||||
splitWeeks,
|
||||
fmtRange,
|
||||
fmtTimeBadge,
|
||||
layoutWeekBars,
|
||||
} from '../../lib/calendar';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { SwipeableTabs } from '../mobile/SwipeableTabs';
|
||||
|
||||
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
|
||||
|
||||
/** 'YYYY-MM' of the viewer's local today. */
|
||||
function localMonth(): string {
|
||||
return localToday().slice(0, 7);
|
||||
}
|
||||
/** month ± delta, as 'YYYY-MM' (calendar arithmetic on the 1st via UTC). */
|
||||
function shiftMonth(month: string, delta: number): string {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
/** All 'YYYY-MM-DD' cells for the month grid, padded to full weeks (Sun-start). */
|
||||
function monthGridDays(month: string): string[] {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const first = new Date(Date.UTC(y, m - 1, 1));
|
||||
const start = shiftDay(first.toISOString().slice(0, 10), -first.getUTCDay());
|
||||
const days: string[] = [];
|
||||
for (let i = 0; i < 42; i++) days.push(shiftDay(start, i));
|
||||
return days;
|
||||
}
|
||||
|
||||
interface CrossSpaceCalendarProps {
|
||||
/** open a space's detail (switches to the Spaces page). */
|
||||
onOpenSpace: (spaceId: string) => void;
|
||||
@@ -39,6 +29,7 @@ interface CrossSpaceCalendarProps {
|
||||
}
|
||||
|
||||
export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalendarProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const tzOffset = localTzOffset();
|
||||
const isMobile = useIsMobile();
|
||||
const [month, setMonth] = useState(localMonth());
|
||||
@@ -51,8 +42,10 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
|
||||
const today = localToday();
|
||||
const days = useMemo(() => monthGridDays(month), [month]);
|
||||
const weeks = useMemo(() => splitWeeks(days), [days]);
|
||||
const counts = monthQuery.data?.days ?? {};
|
||||
const spaces = monthQuery.data?.spaces ?? [];
|
||||
const events = useMemo(() => monthQuery.data?.events ?? [], [monthQuery.data]);
|
||||
const spaceById = useMemo(() => {
|
||||
const m = new Map<string, CrossCalendarSpace>();
|
||||
for (const s of spaces) m.set(s.id, s);
|
||||
@@ -61,7 +54,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
|
||||
const monthLabel = (() => {
|
||||
const [y, m] = month.split('-');
|
||||
return `${y}年${Number(m)}月`;
|
||||
return t('calendar.monthLabel', { year: y, month: Number(m) });
|
||||
})();
|
||||
|
||||
const grid = (
|
||||
@@ -73,7 +66,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
data-testid="cross-cal-prev"
|
||||
onClick={() => setMonth(m => shiftMonth(m, -1))}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
aria-label="前の月"
|
||||
aria-label={t('calendar.prevMonth')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M10 4l-4 4 4 4" /></svg>
|
||||
</button>
|
||||
@@ -83,7 +76,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
data-testid="cross-cal-next"
|
||||
onClick={() => setMonth(m => shiftMonth(m, 1))}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
aria-label="次の月"
|
||||
aria-label={t('calendar.nextMonth')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4l4 4-4 4" /></svg>
|
||||
</button>
|
||||
@@ -96,59 +89,103 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* day cells: one color dot per space with activity that day */}
|
||||
<div data-testid="cross-cal-grid" className="grid grid-cols-7 gap-1">
|
||||
{days.map(d => {
|
||||
const inMonth = d.slice(0, 7) === month;
|
||||
const bySpace = counts[d];
|
||||
const activeSpaceIds = bySpace
|
||||
? Object.keys(bySpace).filter(sid => (bySpace[sid]!.taskCount + bySpace[sid]!.eventCount) > 0)
|
||||
: [];
|
||||
const isToday = d === today;
|
||||
const isSelected = d === selectedDate;
|
||||
{/* day cells(タスクはスペース色のドット)+ 複数日予定の横棒(週ごと・スペース色) */}
|
||||
<div data-testid="cross-cal-grid" className="flex flex-col gap-1">
|
||||
{weeks.map((week, wi) => {
|
||||
const bars = layoutWeekBars(week, events);
|
||||
const laneCount = bars.reduce((m, b) => Math.max(m, b.lane + 1), 0);
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
data-testid={`cross-cal-day-${d}`}
|
||||
onClick={() => setSelectedDate(d)}
|
||||
className={`flex min-h-[3.25rem] flex-col items-stretch gap-1 rounded-md border p-1 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-[var(--brand-primary)] bg-surface'
|
||||
: 'border-hairline hover:bg-surface'
|
||||
} ${inMonth ? '' : 'opacity-40'}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[11px] font-semibold ${
|
||||
isToday
|
||||
? 'inline-flex h-5 w-5 items-center justify-center self-start rounded-full bg-[var(--brand-primary)] text-white'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{Number(d.slice(8, 10))}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-0.5">
|
||||
{activeSpaceIds.slice(0, 6).map(sid => {
|
||||
const sp = spaceById.get(sid);
|
||||
return (
|
||||
<div key={wi} className="grid grid-cols-7 gap-1">
|
||||
{week.map(d => {
|
||||
const inMonth = d.slice(0, 7) === month;
|
||||
const bySpace = counts[d];
|
||||
// ドットは「タスクのある」スペースだけ。予定は下の横棒で表す。
|
||||
const taskSpaceIds = bySpace
|
||||
? Object.keys(bySpace).filter(sid => bySpace[sid]!.taskCount > 0)
|
||||
: [];
|
||||
const isToday = d === today;
|
||||
const isSelected = d === selectedDate;
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
data-testid={`cross-cal-day-${d}`}
|
||||
onClick={() => setSelectedDate(d)}
|
||||
className={`flex min-h-[2.5rem] flex-col items-stretch gap-0.5 rounded-md border p-1 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-[var(--brand-primary)] bg-surface'
|
||||
: 'border-hairline hover:bg-surface'
|
||||
} ${inMonth ? '' : 'opacity-40'}`}
|
||||
>
|
||||
<span
|
||||
key={sid}
|
||||
data-testid={`cross-cal-dot-${sid}`}
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: sp?.color ?? 'var(--brand-primary)' }}
|
||||
title={sp?.name ?? sid}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{activeSpaceIds.length > 6 && (
|
||||
<span className="text-[8px] font-bold text-slate-400">+{activeSpaceIds.length - 6}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
className={`text-[11px] font-semibold ${
|
||||
isToday
|
||||
? 'inline-flex h-5 w-5 items-center justify-center self-start rounded-full bg-[var(--brand-primary)] text-white'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{Number(d.slice(8, 10))}
|
||||
</span>
|
||||
{taskSpaceIds.length > 0 && (
|
||||
<span className="flex flex-wrap gap-0.5">
|
||||
{taskSpaceIds.slice(0, 6).map(sid => {
|
||||
const sp = spaceById.get(sid);
|
||||
return (
|
||||
<span
|
||||
key={sid}
|
||||
data-testid={`cross-cal-dot-${sid}`}
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: sp?.color ?? 'var(--brand-primary)' }}
|
||||
title={t('crossCalendar.dotTitle', { name: sp?.name ?? sid, count: bySpace?.[sid]?.taskCount ?? 0 })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{taskSpaceIds.length > 6 && (
|
||||
<span className="text-[8px] font-bold text-slate-400">+{taskSpaceIds.length - 6}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{bars.length > 0 && (
|
||||
<div
|
||||
className="col-span-7 grid grid-cols-7 gap-x-1 gap-y-0.5 pb-0.5"
|
||||
style={{ gridTemplateRows: `repeat(${laneCount}, 1.05rem)` }}
|
||||
>
|
||||
{bars.map(b => {
|
||||
const sp = spaceById.get(b.ev.spaceId);
|
||||
const color = sp?.color ?? 'var(--brand-primary)';
|
||||
return (
|
||||
<button
|
||||
key={b.ev.id}
|
||||
type="button"
|
||||
data-testid={`cross-cal-bar-${b.ev.id}`}
|
||||
title={`${sp?.name ? t('crossCalendar.barTitlePrefix', { name: sp.name }) : ''}${t('crossCalendar.barTitle', { title: b.ev.title, range: fmtRange(b.ev) })}`}
|
||||
onClick={() => setSelectedDate(week[b.colStart - 1]!)}
|
||||
style={{
|
||||
gridColumn: `${b.colStart} / span ${b.colSpan}`,
|
||||
gridRow: b.lane + 1,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 22%, transparent)`,
|
||||
borderLeft: `2px solid ${color}`,
|
||||
}}
|
||||
className={`flex items-center overflow-hidden whitespace-nowrap px-1 text-[9px] font-semibold leading-none text-slate-700 transition-opacity hover:opacity-80 dark:text-slate-100 ${
|
||||
b.continuesLeft ? 'rounded-l-none' : 'rounded-l'
|
||||
} ${b.continuesRight ? 'rounded-r-none' : 'rounded-r'}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{b.continuesLeft ? '◀ ' : b.ev.time ? `${b.ev.time} ` : ''}{b.ev.title}{b.continuesRight ? ' ▶' : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">カレンダーの取得に失敗しました。</p>}
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">{t('calendar.fetchError')}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -157,6 +194,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
date={selectedDate}
|
||||
tzOffset={tzOffset}
|
||||
counts={counts[selectedDate] ?? {}}
|
||||
events={events}
|
||||
spaces={spaces}
|
||||
onOpenSpace={onOpenSpace}
|
||||
onOpenTask={onOpenTask}
|
||||
@@ -164,25 +202,25 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
|
||||
日付を選ぶと、その日の各ワークスペースのタスク・予定が表示されます。
|
||||
{t('crossCalendar.emptyHint')}
|
||||
</div>
|
||||
);
|
||||
|
||||
// モバイル: 月の左右スワイプで月送り。日を選ぶと下にオーバーレイで日詳細を出す。
|
||||
// モバイル: 上に月グリッド(左右スワイプで月送り)、下に日詳細の上下 2 分割。
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div data-testid="cross-calendar" className="flex h-full flex-col overflow-hidden">
|
||||
<SwipeableTabs
|
||||
tabs={[shiftMonth(month, -1), month, shiftMonth(month, 1)]}
|
||||
activeTab={month}
|
||||
onTabChange={(m) => setMonth(m)}
|
||||
renderTab={(m) => (m === month ? <div className="p-3 overflow-y-auto h-full">{grid}</div> : <div className="h-full" />)}
|
||||
/>
|
||||
{selectedDate && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-canvas">
|
||||
{panel}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<SwipeableTabs
|
||||
tabs={[shiftMonth(month, -1), month, shiftMonth(month, 1)]}
|
||||
activeTab={month}
|
||||
onTabChange={(m) => setMonth(m)}
|
||||
renderTab={(m) => (m === month ? <div className="p-3 overflow-y-auto h-full">{grid}</div> : <div className="h-full" />)}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="cross-cal-mobile-detail" className="h-[45%] min-h-0 shrink-0 overflow-hidden border-t border-hairline">
|
||||
{panel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,6 +238,7 @@ function CrossDayPanel({
|
||||
date,
|
||||
tzOffset,
|
||||
counts,
|
||||
events,
|
||||
spaces,
|
||||
onOpenSpace,
|
||||
onOpenTask,
|
||||
@@ -209,19 +248,33 @@ function CrossDayPanel({
|
||||
tzOffset: number;
|
||||
/** {[spaceId]: {taskCount,eventCount}} for this day (from the month aggregate). */
|
||||
counts: Record<string, { taskCount: number; eventCount: number }>;
|
||||
/** その月の全イベント(spaceId 付き)。月集計のクリップ外の日でもバーから補完する。 */
|
||||
events: CalendarEvent[];
|
||||
spaces: CrossCalendarSpace[];
|
||||
onOpenSpace: (spaceId: string) => void;
|
||||
onOpenTask: (taskId: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t: tr } = useTranslation('spaces');
|
||||
// 月集計 counts は当月内にクリップされるため、月境界をまたぐ予定の隣月側の日を
|
||||
// 選ぶと活動なし扱いになってしまう。選択日に重なる予定の spaceId を直接補完する。
|
||||
const eventSpaceIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const ev of events) {
|
||||
const end = ev.endDate && ev.endDate > ev.date ? ev.endDate : ev.date;
|
||||
if (ev.date <= date && date <= end) ids.add(ev.spaceId);
|
||||
}
|
||||
return ids;
|
||||
}, [events, date]);
|
||||
|
||||
// Only fetch the per-space day detail for spaces that have activity on this
|
||||
// day — avoids a second cross endpoint and avoids N empty fetches.
|
||||
const activeSpaces = useMemo(
|
||||
() => spaces.filter(s => {
|
||||
const c = counts[s.id];
|
||||
return c && (c.taskCount + c.eventCount) > 0;
|
||||
return (c && (c.taskCount + c.eventCount) > 0) || eventSpaceIds.has(s.id);
|
||||
}),
|
||||
[spaces, counts],
|
||||
[spaces, counts, eventSpaceIds],
|
||||
);
|
||||
|
||||
const dayQueries = useQueries({
|
||||
@@ -240,7 +293,7 @@ function CrossDayPanel({
|
||||
data-testid="cross-cal-day-close"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 hover:bg-surface hover:text-slate-700"
|
||||
aria-label="閉じる"
|
||||
aria-label={tr('crossCalendar.close')}
|
||||
>
|
||||
<svg className="h-4 w-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>
|
||||
@@ -248,7 +301,7 @@ function CrossDayPanel({
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-4">
|
||||
{activeSpaces.length === 0 && (
|
||||
<p className="text-xs text-slate-400">この日に活動のあるワークスペースはありません。</p>
|
||||
<p className="text-xs text-slate-400">{tr('crossCalendar.noActiveSpaces')}</p>
|
||||
)}
|
||||
{activeSpaces.map((sp, i) => {
|
||||
const day = dayQueries[i]?.data;
|
||||
@@ -274,7 +327,7 @@ function CrossDayPanel({
|
||||
onClick={() => onOpenTask(t.id)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-left transition-colors hover:bg-surface"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || `タスク #${t.id}`}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || tr('crossCalendar.taskFallback', { id: t.id })}</span>
|
||||
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{t.status ?? t.state}</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -285,17 +338,20 @@ function CrossDayPanel({
|
||||
{/* 予定 */}
|
||||
{day && day.events.length > 0 && (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{day.events.map(ev => (
|
||||
{day.events.map((ev: CalendarEvent) => (
|
||||
<li
|
||||
key={ev.id}
|
||||
data-testid={`cross-cal-event-${ev.id}`}
|
||||
className="flex items-start gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5"
|
||||
>
|
||||
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||
{ev.time ?? '終日'}
|
||||
{fmtTimeBadge(ev)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
|
||||
{ev.endDate && ev.endDate > ev.date && (
|
||||
<div className="text-[10px] font-medium text-amber-700 dark:text-amber-300">🗓 {fmtRange(ev)}</div>
|
||||
)}
|
||||
{ev.description && <div className="truncate text-[11px] text-slate-500">{ev.description}</div>}
|
||||
</div>
|
||||
</li>
|
||||
@@ -304,7 +360,7 @@ function CrossDayPanel({
|
||||
)}
|
||||
|
||||
{day && day.tasks.length === 0 && day.events.length === 0 && (
|
||||
<p className="text-xs text-slate-400">表示できる項目がありません。</p>
|
||||
<p className="text-xs text-slate-400">{tr('crossCalendar.noItems')}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -11,18 +11,13 @@
|
||||
* spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
fetchInvitePreview,
|
||||
acceptSpaceInvite,
|
||||
type InvitePreview,
|
||||
type SpaceInviteRole,
|
||||
} from '../../api';
|
||||
|
||||
const ROLE_LABEL: Record<SpaceInviteRole, string> = {
|
||||
editor: '編集者',
|
||||
viewer: '閲覧者',
|
||||
};
|
||||
|
||||
type State =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'unauthorized' }
|
||||
@@ -40,6 +35,7 @@ function Shell({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export function JoinSpace({ token }: { token: string }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [state, setState] = useState<State>({ kind: 'loading' });
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -83,16 +79,16 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
const returnTo = encodeURIComponent(window.location.pathname);
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-2 text-base font-semibold text-slate-900">ワークスペースへの招待</h1>
|
||||
<h1 className="mb-2 text-base font-semibold text-slate-900">{t('joinSpace.title')}</h1>
|
||||
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
|
||||
参加するにはログインが必要です。ログイン後、この招待ページに戻ります。
|
||||
{t('joinSpace.unauthorized.body')}
|
||||
</p>
|
||||
<a
|
||||
href={`/auth/login?returnTo=${returnTo}`}
|
||||
data-testid="join-space-login"
|
||||
className="inline-block rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-fg hover:bg-accent-deep"
|
||||
>
|
||||
ログインして参加
|
||||
{t('joinSpace.unauthorized.login')}
|
||||
</a>
|
||||
</Shell>
|
||||
);
|
||||
@@ -101,12 +97,12 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
if (state.kind === 'invalid') {
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-2 text-base font-semibold text-slate-900">リンクが無効です</h1>
|
||||
<h1 className="mb-2 text-base font-semibold text-slate-900">{t('joinSpace.invalid.title')}</h1>
|
||||
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
|
||||
この招待リンクは期限切れか、取り消された可能性があります。共有元にもう一度リンクの発行を依頼してください。
|
||||
{t('joinSpace.invalid.body')}
|
||||
</p>
|
||||
<a href="/ui/" className="text-[13px] text-slate-600 underline hover:text-slate-900">
|
||||
ホームへ
|
||||
{t('joinSpace.invalid.home')}
|
||||
</a>
|
||||
</Shell>
|
||||
);
|
||||
@@ -115,11 +111,11 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
// ok
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-1 text-base font-semibold text-slate-900">ワークスペースへの招待</h1>
|
||||
<h1 className="mb-1 text-base font-semibold text-slate-900">{t('joinSpace.title')}</h1>
|
||||
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{state.preview.spaceTitle}</span> に
|
||||
<span className="font-semibold text-slate-900">「{ROLE_LABEL[state.preview.role]}」</span>
|
||||
として参加します。
|
||||
<span className="font-semibold text-slate-900">{state.preview.spaceTitle}</span>{t('joinSpace.ok.joinAs.lead')}
|
||||
<span className="font-semibold text-slate-900">{t('joinSpace.ok.joinAs.roleQuoted', { role: t(`joinSpace.role.${state.preview.role}`) })}</span>
|
||||
{t('joinSpace.ok.joinAs.trail')}
|
||||
</p>
|
||||
{error && <div className="mb-3 text-[13px] text-red-600">{error}</div>}
|
||||
<div className="flex justify-center gap-2">
|
||||
@@ -127,7 +123,7 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
href="/ui/"
|
||||
className="rounded-md border border-hairline px-4 py-2 text-sm text-slate-700 hover:bg-surface"
|
||||
>
|
||||
やめる
|
||||
{t('joinSpace.ok.cancel')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
@@ -136,7 +132,7 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
disabled={joining}
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-fg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{joining ? '参加中…' : '参加する'}
|
||||
{joining ? t('joinSpace.ok.joining') : t('joinSpace.ok.join')}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchSpaceFiles, fetchSpaceFileContent } from '../../api';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceFiles,
|
||||
fetchSpaceFileContent,
|
||||
getAppShareLink,
|
||||
createAppShareLink,
|
||||
revokeAppShareLink,
|
||||
} from '../../api';
|
||||
import { deriveAppList, type WorkspaceApp } from './app-bridge';
|
||||
import { AppRunner } from './AppRunner';
|
||||
import { createSpaceGateway } from './app-file-gateway';
|
||||
import { buildAppShareDisplayUrl } from './appShareUrl';
|
||||
|
||||
/**
|
||||
* SpaceApps — the workspace "アプリ" tab.
|
||||
@@ -17,7 +26,8 @@ import { AppRunner } from './AppRunner';
|
||||
* NETWORK / SECURITY: the apps themselves run under AppRunner's opaque-origin
|
||||
* sandbox with `connect-src 'none'` — this tab only discovers + launches them.
|
||||
*/
|
||||
export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
export function SpaceApps({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [appToRun, setAppToRun] = useState<WorkspaceApp | null>(null);
|
||||
|
||||
const appsQuery = useQuery({
|
||||
@@ -32,15 +42,15 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
<div data-testid="space-apps" className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-500">
|
||||
ワークスペース・アプリ
|
||||
{t('apps.heading')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-apps-refresh"
|
||||
onClick={() => void appsQuery.refetch()}
|
||||
disabled={appsQuery.isFetching}
|
||||
title="再読み込み"
|
||||
aria-label="再読み込み"
|
||||
title={t('apps.refresh')}
|
||||
aria-label={t('apps.refresh')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
<svg className={`h-3.5 w-3.5 ${appsQuery.isFetching ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -51,7 +61,7 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
</div>
|
||||
|
||||
{appsQuery.isError && (
|
||||
<p className="text-xs text-red-600">アプリ一覧の取得に失敗しました。</p>
|
||||
<p className="text-xs text-red-600">{t('apps.fetchError')}</p>
|
||||
)}
|
||||
|
||||
{!appsQuery.isLoading && !appsQuery.isError && apps.length === 0 && (
|
||||
@@ -59,12 +69,16 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
data-testid="space-apps-empty"
|
||||
className="rounded-lg border border-dashed border-hairline bg-surface p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
<p className="font-medium text-slate-600">まだアプリがありません</p>
|
||||
<p className="font-medium text-slate-600">{t('apps.empty.title')}</p>
|
||||
<p className="mt-1.5 leading-relaxed">
|
||||
エージェントに「ワークスペース・アプリを作って」と頼むと、ここに表示されます。
|
||||
{t('apps.empty.body')}
|
||||
</p>
|
||||
<p className="mt-1.5 text-2xs text-slate-400">
|
||||
アプリはワークスペースの <code className="rounded bg-canvas px-1 py-0.5">apps/</code> フォルダ(<code className="rounded bg-canvas px-1 py-0.5">apps/{名前}/index.html</code>)に置かれます。
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="apps.empty.location"
|
||||
components={{ code: <code className="rounded bg-canvas px-1 py-0.5" /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -106,8 +120,10 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" stroke="none">
|
||||
<path d="M5 3.5v9l7-4.5z" />
|
||||
</svg>
|
||||
開く
|
||||
{t('apps.open')}
|
||||
</button>
|
||||
|
||||
{canManage && <AppShareControls spaceId={spaceId} appName={app.name} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -115,7 +131,7 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
|
||||
{appToRun && (
|
||||
<AppRunner
|
||||
spaceId={spaceId}
|
||||
gateway={createSpaceGateway(spaceId)}
|
||||
appName={appToRun.name}
|
||||
entryPath={appToRun.entryPath}
|
||||
onClose={() => setAppToRun(null)}
|
||||
@@ -182,3 +198,138 @@ async function loadWorkspaceApps(spaceId: string): Promise<WorkspaceApp[]> {
|
||||
name => indexMap.get(name)?.manifest ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AppShareControls — 1 アプリの公開共有リンク管理(canManage のみ表示)。
|
||||
*
|
||||
* 現在のリンク状態を取得し、未発行なら「リンクを作成」、発行済みなら公開 URL の表示・
|
||||
* コピー・失効を提供する。発行/失効は react-query mutation。公開リンクは read-only・
|
||||
* ログイン不要・apps/{app}/+output/ に封じ込められる旨を注意書きで明示する。
|
||||
*/
|
||||
function AppShareControls({ spaceId, appName }: { spaceId: string; appName: string }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const queryKey = ['app-share-link', spaceId, appName];
|
||||
|
||||
const linkQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => getAppShareLink(spaceId, appName),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createAppShareLink(spaceId, appName),
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(queryKey, { token: data.token, shareUrl: data.shareUrl, revokedAt: null });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: () => revokeAppShareLink(spaceId, appName),
|
||||
onSuccess: () => {
|
||||
qc.setQueryData(queryKey, { token: null, revokedAt: new Date().toISOString() });
|
||||
setCopied(false);
|
||||
},
|
||||
});
|
||||
|
||||
const link = linkQuery.data;
|
||||
const shareUrl = link?.token && link.shareUrl ? link.shareUrl : null;
|
||||
const displayUrl = shareUrl ? buildAppShareDisplayUrl(window.location.origin, shareUrl) : null;
|
||||
|
||||
const copy = async () => {
|
||||
if (!displayUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayUrl);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// clipboard 不可(権限なし等)。表示済み URL から手動コピーできるので無視。
|
||||
}
|
||||
};
|
||||
|
||||
const onRevoke = () => {
|
||||
if (window.confirm(t('appShare.revokeConfirm', { appName }))) {
|
||||
revokeMut.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const busy = createMut.isPending || revokeMut.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`space-app-share-${appName}`}
|
||||
className="mt-1 border-t border-hairline pt-2"
|
||||
>
|
||||
{linkQuery.isLoading ? (
|
||||
<p className="text-2xs text-slate-400">{t('appShare.checking')}</p>
|
||||
) : displayUrl ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-2xs font-semibold text-emerald-600 dark:text-emerald-400"
|
||||
data-testid={`space-app-share-badge-${appName}`}
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
|
||||
</svg>
|
||||
{t('appShare.issued')}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={displayUrl}
|
||||
data-testid={`space-app-share-url-${appName}`}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="w-full truncate rounded border border-hairline bg-canvas px-2 py-1 font-mono text-2xs text-slate-600"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-share-copy-${appName}`}
|
||||
onClick={() => void copy()}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface"
|
||||
>
|
||||
{copied ? t('appShare.copied') : t('appShare.copyUrl')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-share-revoke-${appName}`}
|
||||
onClick={onRevoke}
|
||||
disabled={busy}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-950/30"
|
||||
>
|
||||
{t('appShare.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-2xs leading-relaxed text-amber-600">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="appShare.warning"
|
||||
components={{ code: <code className="rounded bg-canvas px-1" />, strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-share-create-${appName}`}
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={busy}
|
||||
className="inline-flex h-7 items-center gap-1 self-start rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
|
||||
</svg>
|
||||
{createMut.isPending ? t('appShare.creating') : t('appShare.create')}
|
||||
</button>
|
||||
{(createMut.isError || revokeMut.isError) && (
|
||||
<p className="text-2xs text-red-600">{t('appShare.opError')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* ときだけ管理コントロールを出す。判定できない場合でも 403 はトーストで処理。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
listBrowserSessionProfiles, deleteBrowserSessionProfile, testBrowserSessionProfile,
|
||||
@@ -28,14 +29,6 @@ import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
const STATUS_LABEL: Record<BrowserSessionProfile['status'], string> = {
|
||||
pending: '保存待ち',
|
||||
active: '有効',
|
||||
expired: '期限切れ',
|
||||
revoked: '失効',
|
||||
error: 'エラー',
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<BrowserSessionProfile['status'], string> = {
|
||||
pending: 'bg-slate-200 text-slate-700',
|
||||
active: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
||||
@@ -45,9 +38,10 @@ const STATUS_CLASS: Record<BrowserSessionProfile['status'], string> = {
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: BrowserSessionProfile['status'] }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded px-2 py-0.5 text-2xs font-medium ${STATUS_CLASS[status]}`}>
|
||||
{STATUS_LABEL[status]}
|
||||
{t(`browser.status.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +51,7 @@ function errMsg(e: unknown): string {
|
||||
}
|
||||
|
||||
export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -81,12 +76,12 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
const delSess = useMutation({
|
||||
mutationFn: (id: number) => deleteBrowserSessionProfile(id, spaceId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }),
|
||||
onError: (e) => showToast?.(`セッションの削除に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('browser.toast.sessionDeleteFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
const testSess = useMutation({
|
||||
mutationFn: (id: number) => testBrowserSessionProfile(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }),
|
||||
onError: (e) => showToast?.(`セッションの検証に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('browser.toast.sessionTestFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
@@ -102,12 +97,12 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
const delMacro = useMutation({
|
||||
mutationFn: (name: string) => deleteFolderFile('browser-macros', name, spaceId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-macros', spaceId] }),
|
||||
onError: (e) => showToast?.(`削除に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('browser.toast.deleteFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
const delRecording = useMutation({
|
||||
mutationFn: (name: string) => deleteFolderFile('recordings', name, spaceId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-recordings', spaceId] }),
|
||||
onError: (e) => showToast?.(`削除に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('browser.toast.deleteFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
// ファイル内容プレビュー(マクロ・録画共通)。
|
||||
@@ -117,7 +112,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
const content = await getFolderFile(subdir, name, spaceId);
|
||||
setPreview({ name, content });
|
||||
} catch (e) {
|
||||
showToast?.(`内容の取得に失敗しました: ${errMsg(e)}`, 'error');
|
||||
showToast?.(t('browser.toast.contentFetchFailed', { msg: errMsg(e) }), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +122,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
{/* ── セッション ── */}
|
||||
<section data-testid="space-browser-sessions">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-800">ブラウザセッション</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('browser.sessions.heading')}</h2>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -135,22 +130,21 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
onClick={() => setAdding(true)}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:bg-accent-deep"
|
||||
>
|
||||
セッションを追加
|
||||
{t('browser.sessions.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-slate-500">
|
||||
このワークスペースで共有するログイン済みブラウザセッション。各セッションは
|
||||
作成者の鍵で暗号化されるため、利用できるのは作成した本人だけです。
|
||||
{t('browser.sessions.intro')}
|
||||
</p>
|
||||
|
||||
{sessLoading && <div className="text-xs text-slate-500">読み込み中…</div>}
|
||||
{sessLoading && <div className="text-xs text-slate-500">{t('common:loading')}</div>}
|
||||
|
||||
<div className="divide-y divide-hairline rounded-md border border-hairline">
|
||||
{profiles.length === 0 && !sessLoading && (
|
||||
<div className="px-3 py-6 text-center text-xs text-slate-400">
|
||||
<div>このワークスペースにはまだブラウザセッションがありません。</div>
|
||||
{canManage && <div className="mt-1">「セッションを追加」からログインして保存してください。</div>}
|
||||
<div>{t('browser.sessions.empty')}</div>
|
||||
{canManage && <div className="mt-1">{t('browser.sessions.emptyHint')}</div>}
|
||||
</div>
|
||||
)}
|
||||
{profiles.map(p => {
|
||||
@@ -163,7 +157,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
<StatusPill status={p.status} />
|
||||
{!usable && (
|
||||
<span className="inline-flex items-center rounded bg-slate-200 px-2 py-0.5 text-2xs font-medium text-slate-600">
|
||||
作成者のみ利用可
|
||||
{t('browser.sessions.creatorOnly')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -171,7 +165,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
{p.lastError && <div className="truncate text-2xs text-rose-600">{p.lastError}</div>}
|
||||
{!usable && (
|
||||
<div className="text-2xs text-slate-400">
|
||||
このセッションは作成者の鍵で暗号化されています。閲覧はできますが、別のメンバーは復号・利用できません。
|
||||
{t('browser.sessions.creatorOnlyHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -183,16 +177,16 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
disabled={testSess.isPending}
|
||||
className="rounded px-2 py-1 text-xs text-slate-700 hover:bg-surface hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
検証
|
||||
{t('browser.sessions.test')}
|
||||
</button>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (confirm(`「${p.label}」を削除しますか?`)) delSess.mutate(p.id); }}
|
||||
onClick={() => { if (confirm(t('browser.deleteConfirm', { name: p.label }))) delSess.mutate(p.id); }}
|
||||
className="rounded px-2 py-1 text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-800 dark:hover:bg-rose-500/15 dark:hover:text-rose-300"
|
||||
>
|
||||
削除
|
||||
{t('common:delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -204,28 +198,28 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
|
||||
{/* ── マクロ ── */}
|
||||
<FolderSection
|
||||
title="ブラウザマクロ"
|
||||
title={t('browser.macros.title')}
|
||||
testid="space-browser-macros"
|
||||
subdir="browser-macros"
|
||||
query={macros}
|
||||
emptyText="このワークスペースにはまだブラウザマクロがありません。"
|
||||
hint="エージェントがブラウザ操作を記録すると、このワークスペースのマクロとして保存されます。"
|
||||
emptyText={t('browser.macros.empty')}
|
||||
hint={t('browser.macros.hint')}
|
||||
canManage={canManage}
|
||||
onView={(name) => openPreview('browser-macros', name)}
|
||||
onDelete={(name) => { if (confirm(`「${name}」を削除しますか?`)) delMacro.mutate(name); }}
|
||||
onDelete={(name) => { if (confirm(t('browser.deleteConfirm', { name }))) delMacro.mutate(name); }}
|
||||
/>
|
||||
|
||||
{/* ── 録画 ── */}
|
||||
<FolderSection
|
||||
title="録画"
|
||||
title={t('browser.recordings.title')}
|
||||
testid="space-browser-recordings"
|
||||
subdir="recordings"
|
||||
query={recordings}
|
||||
emptyText="このワークスペースにはまだ録画がありません。"
|
||||
hint="ブラウザ操作の記録(録画)がこのワークスペースのフォルダに保存されます。"
|
||||
emptyText={t('browser.recordings.empty')}
|
||||
hint={t('browser.recordings.hint')}
|
||||
canManage={canManage}
|
||||
onView={(name) => openPreview('recordings', name)}
|
||||
onDelete={(name) => { if (confirm(`「${name}」を削除しますか?`)) delRecording.mutate(name); }}
|
||||
onDelete={(name) => { if (confirm(t('browser.deleteConfirm', { name }))) delRecording.mutate(name); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -253,12 +247,13 @@ interface FolderSectionProps {
|
||||
}
|
||||
|
||||
function FolderSection({ title, testid, query, emptyText, hint, canManage, onView, onDelete }: FolderSectionProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const files = query.data ?? [];
|
||||
return (
|
||||
<section data-testid={testid}>
|
||||
<h2 className="mb-2 text-base font-semibold text-slate-800">{title}</h2>
|
||||
<p className="mb-3 text-xs text-slate-500">{hint}</p>
|
||||
{query.isLoading && <div className="text-xs text-slate-500">読み込み中…</div>}
|
||||
{query.isLoading && <div className="text-xs text-slate-500">{t('common:loading')}</div>}
|
||||
<div className="divide-y divide-hairline rounded-md border border-hairline">
|
||||
{files.length === 0 && !query.isLoading && (
|
||||
<div className="px-3 py-6 text-center text-xs text-slate-400">{emptyText}</div>
|
||||
@@ -279,7 +274,7 @@ function FolderSection({ title, testid, query, emptyText, hint, canManage, onVie
|
||||
onClick={() => onDelete(f.name)}
|
||||
className="ml-2 shrink-0 rounded px-2 py-1 text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-800 dark:hover:bg-rose-500/15 dark:hover:text-rose-300"
|
||||
>
|
||||
削除
|
||||
{t('common:delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceCalendarMonth,
|
||||
@@ -8,60 +9,40 @@ import {
|
||||
deleteCalendarEvent,
|
||||
fetchSpaceFileContent,
|
||||
getSpaceFileRawUrl,
|
||||
getSpaceFileOfficePreviewUrl,
|
||||
getSpaceTrustedHtmlUrl,
|
||||
type CalendarEvent,
|
||||
} from '../../api';
|
||||
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
|
||||
import { localToday, localTzOffset } from '../../lib/localDate';
|
||||
import {
|
||||
WEEKDAYS,
|
||||
localMonth,
|
||||
shiftMonth,
|
||||
monthGridDays,
|
||||
splitWeeks,
|
||||
fmtRange,
|
||||
fmtTimeBadge,
|
||||
layoutWeekBars,
|
||||
} from '../../lib/calendar';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { FilePreview } from '../files/FilePreview';
|
||||
import type { OfficePreviewDescriptor } from '../files/FilePreview';
|
||||
import { FileTypeIcon } from '../files/FileTypeIcon';
|
||||
import { SwipeableTabs } from '../mobile/SwipeableTabs';
|
||||
|
||||
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
|
||||
|
||||
/** 'YYYY-MM' of the viewer's local today. */
|
||||
function localMonth(): string {
|
||||
return localToday().slice(0, 7);
|
||||
}
|
||||
/** month ± delta, as 'YYYY-MM' (calendar arithmetic on the 1st via UTC). */
|
||||
function shiftMonth(month: string, delta: number): string {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
/** All 'YYYY-MM-DD' cells for the month grid, padded to full weeks (Sun-start). */
|
||||
function monthGridDays(month: string): string[] {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const first = new Date(Date.UTC(y, m - 1, 1));
|
||||
const start = shiftDay(first.toISOString().slice(0, 10), -first.getUTCDay());
|
||||
const days: string[] = [];
|
||||
// 6 weeks always covers any month layout (max 31 days + 6 lead).
|
||||
for (let i = 0; i < 42; i++) days.push(shiftDay(start, i));
|
||||
return days;
|
||||
}
|
||||
function fmtSize(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}MB`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}KB`;
|
||||
return `${n}B`;
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 'M/D'(月グリッドのバー・期間表示用)。 */
|
||||
function fmtMonthDay(d: string): string {
|
||||
return `${Number(d.slice(5, 7))}/${Number(d.slice(8, 10))}`;
|
||||
}
|
||||
/** 予定の期間を短く: 単日は 'M/D'、複数日は 'M/D–M/D'。 */
|
||||
function fmtRange(ev: CalendarEvent): string {
|
||||
const end = ev.endDate && ev.endDate > ev.date ? ev.endDate : null;
|
||||
return end ? `${fmtMonthDay(ev.date)}–${fmtMonthDay(end)}` : fmtMonthDay(ev.date);
|
||||
}
|
||||
|
||||
// ── カレンダーの表示フィルター(タスク / 変更ファイル / 予定)─────────────
|
||||
type CalFilters = { tasks: boolean; files: boolean; events: boolean };
|
||||
const FILTER_DEFS: ReadonlyArray<{ key: keyof CalFilters; label: string; icon: string }> = [
|
||||
{ key: 'tasks', label: 'タスク', icon: '💬' },
|
||||
{ key: 'files', label: '変更ファイル', icon: '📄' },
|
||||
{ key: 'events', label: '予定', icon: '📌' },
|
||||
const FILTER_DEFS: ReadonlyArray<{ key: keyof CalFilters; labelKey: string; icon: string }> = [
|
||||
{ key: 'tasks', labelKey: 'calendar.filter.tasks', icon: '💬' },
|
||||
{ key: 'files', labelKey: 'calendar.filter.files', icon: '📄' },
|
||||
{ key: 'events', labelKey: 'calendar.filter.events', icon: '📌' },
|
||||
];
|
||||
const FILTERS_STORAGE_KEY = 'spaceCalendarFilters';
|
||||
function loadFilters(): CalFilters {
|
||||
@@ -75,49 +56,6 @@ function loadFilters(): CalFilters {
|
||||
return { tasks: true, files: true, events: true };
|
||||
}
|
||||
|
||||
/** 1 週(7 日)にかかるイベントを、横棒の lane(重ならない行)に割り付ける。 */
|
||||
interface WeekBar {
|
||||
ev: CalendarEvent;
|
||||
colStart: number; // 1–7
|
||||
colSpan: number;
|
||||
lane: number; // 0-based の積み上げ行
|
||||
continuesLeft: boolean;
|
||||
continuesRight: boolean;
|
||||
}
|
||||
function layoutWeekBars(weekDays: string[], events: CalendarEvent[]): WeekBar[] {
|
||||
const weekStart = weekDays[0]!;
|
||||
const weekEnd = weekDays[6]!;
|
||||
const segs = events
|
||||
.map((ev) => {
|
||||
const evEnd = ev.endDate && ev.endDate > ev.date ? ev.endDate : ev.date;
|
||||
if (evEnd < weekStart || ev.date > weekEnd) return null;
|
||||
const segStart = ev.date < weekStart ? weekStart : ev.date;
|
||||
const segEnd = evEnd > weekEnd ? weekEnd : evEnd;
|
||||
const colStart = weekDays.indexOf(segStart) + 1;
|
||||
const colEnd = weekDays.indexOf(segEnd) + 1;
|
||||
return {
|
||||
ev,
|
||||
colStart,
|
||||
colSpan: colEnd - colStart + 1,
|
||||
continuesLeft: ev.date < weekStart,
|
||||
continuesRight: evEnd > weekEnd,
|
||||
};
|
||||
})
|
||||
.filter((s): s is Omit<WeekBar, 'lane'> => s !== null)
|
||||
// 長い棒・早い開始を優先して上の lane に積む
|
||||
.sort((a, b) => b.colSpan - a.colSpan || a.colStart - b.colStart || a.ev.id - b.ev.id);
|
||||
|
||||
const laneEnds: number[] = []; // lane ごとの「最後に埋まった列」
|
||||
const bars: WeekBar[] = [];
|
||||
for (const s of segs) {
|
||||
let lane = laneEnds.findIndex((end) => end < s.colStart);
|
||||
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0); }
|
||||
laneEnds[lane] = s.colStart + s.colSpan - 1;
|
||||
bars.push({ ...s, lane });
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
|
||||
interface SpaceCalendarProps {
|
||||
spaceId: string;
|
||||
/** open the chat for a task created in this space (switches to the chat tab). */
|
||||
@@ -127,6 +65,7 @@ interface SpaceCalendarProps {
|
||||
}
|
||||
|
||||
export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const tzOffset = localTzOffset();
|
||||
const isMobile = useIsMobile();
|
||||
const [month, setMonth] = useState(localMonth());
|
||||
@@ -148,16 +87,12 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
|
||||
const today = localToday();
|
||||
const days = useMemo(() => monthGridDays(month), [month]);
|
||||
const weeks = useMemo(() => {
|
||||
const out: string[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
|
||||
return out;
|
||||
}, [days]);
|
||||
const weeks = useMemo(() => splitWeeks(days), [days]);
|
||||
const counts = monthQuery.data?.days ?? {};
|
||||
const events = useMemo(() => monthQuery.data?.events ?? [], [monthQuery.data]);
|
||||
const monthLabel = (() => {
|
||||
const [y, m] = month.split('-');
|
||||
return `${y}年${Number(m)}月`;
|
||||
return t('calendar.monthLabel', { year: y, month: Number(m) });
|
||||
})();
|
||||
|
||||
const grid = (
|
||||
@@ -169,7 +104,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
data-testid="space-cal-prev"
|
||||
onClick={() => setMonth(m => shiftMonth(m, -1))}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
aria-label="前の月"
|
||||
aria-label={t('calendar.prevMonth')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M10 4l-4 4 4 4" /></svg>
|
||||
</button>
|
||||
@@ -179,7 +114,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
data-testid="space-cal-next"
|
||||
onClick={() => setMonth(m => shiftMonth(m, 1))}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
aria-label="次の月"
|
||||
aria-label={t('calendar.nextMonth')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4l4 4-4 4" /></svg>
|
||||
</button>
|
||||
@@ -202,7 +137,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
: 'border-hairline bg-canvas text-slate-400 line-through'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{f.icon}</span>{f.label}
|
||||
<span aria-hidden>{f.icon}</span>{t(f.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -249,7 +184,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
{Number(d.slice(8, 10))}
|
||||
</span>
|
||||
{filters.tasks && c?.taskCount ? (
|
||||
<span className="self-start rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={`タスク ${c.taskCount} 件`}>
|
||||
<span className="self-start rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={t('calendar.taskCount', { count: c.taskCount })}>
|
||||
💬{c.taskCount}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -266,7 +201,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
key={b.ev.id}
|
||||
type="button"
|
||||
data-testid={`space-cal-bar-${b.ev.id}`}
|
||||
title={`${b.ev.title}(${fmtRange(b.ev)})`}
|
||||
title={t('calendar.barTitle', { title: b.ev.title, range: fmtRange(b.ev) })}
|
||||
onClick={() => setSelectedDate(week[b.colStart - 1]!)}
|
||||
style={{ gridColumn: `${b.colStart} / span ${b.colSpan}`, gridRow: b.lane + 1 }}
|
||||
className={`flex items-center overflow-hidden whitespace-nowrap bg-amber-100 px-1 text-[9px] font-semibold leading-none text-amber-800 transition-colors hover:bg-amber-200 dark:bg-amber-500/25 dark:text-amber-200 ${
|
||||
@@ -284,7 +219,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">カレンダーの取得に失敗しました。</p>}
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">{t('calendar.fetchError')}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -301,7 +236,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
|
||||
日付を選ぶと、その日のタスク・変更ファイル・予定が表示されます。
|
||||
{t('calendar.emptyHint')}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -339,6 +274,7 @@ interface DayPanelPreview {
|
||||
imageSrc: string;
|
||||
markdownImageBaseUrl?: string;
|
||||
trustedHtmlUrl?: string;
|
||||
office?: OfficePreviewDescriptor;
|
||||
}
|
||||
|
||||
function DayPanel({
|
||||
@@ -360,6 +296,7 @@ function DayPanel({
|
||||
onOpenChat: (taskId: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const dayQuery = useQuery({
|
||||
queryKey: ['spaceCalendarDay', spaceId, date, tzOffset],
|
||||
@@ -376,6 +313,16 @@ function DayPanel({
|
||||
|
||||
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;
|
||||
@@ -405,7 +352,7 @@ function DayPanel({
|
||||
data-testid="space-cal-day-close"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 hover:bg-surface hover:text-slate-700"
|
||||
aria-label="閉じる"
|
||||
aria-label={t('calendar.close')}
|
||||
>
|
||||
<svg className="h-4 w-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>
|
||||
@@ -415,25 +362,25 @@ function DayPanel({
|
||||
{/* タスク */}
|
||||
{filters.tasks && (
|
||||
<section>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">タスク</h4>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.tasks')}</h4>
|
||||
{day && day.tasks.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{day.tasks.map(t => (
|
||||
<li key={t.id}>
|
||||
{day.tasks.map(task => (
|
||||
<li key={task.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-cal-task-${t.id}`}
|
||||
onClick={() => onOpenChat(t.id)}
|
||||
data-testid={`space-cal-task-${task.id}`}
|
||||
onClick={() => onOpenChat(task.id)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-left transition-colors hover:bg-surface"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || `タスク #${t.id}`}</span>
|
||||
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{t.status ?? t.state}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{task.title || t('calendar.taskFallback', { id: task.id })}</span>
|
||||
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{task.status ?? task.state}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">この日のタスクはありません。</p>
|
||||
<p className="text-xs text-slate-400">{t('calendar.day.noTasks')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -441,7 +388,7 @@ function DayPanel({
|
||||
{/* 変更ファイル */}
|
||||
{filters.files && (
|
||||
<section>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">変更ファイル</h4>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.changedFiles')}</h4>
|
||||
{day && day.files.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{day.files.map(f => (
|
||||
@@ -462,7 +409,7 @@ function DayPanel({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">この日に変更されたファイルはありません。</p>
|
||||
<p className="text-xs text-slate-400">{t('calendar.day.noChangedFiles')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -471,7 +418,7 @@ function DayPanel({
|
||||
{filters.events && (
|
||||
<section>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<h4 className="text-2xs font-bold uppercase tracking-wider text-slate-500">予定</h4>
|
||||
<h4 className="text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.events')}</h4>
|
||||
{canEdit && !showAdd && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -479,7 +426,7 @@ function DayPanel({
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="rounded-md border border-hairline bg-canvas px-2 py-0.5 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
>
|
||||
+ 予定を追加
|
||||
{t('calendar.day.addEvent')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -503,7 +450,7 @@ function DayPanel({
|
||||
className="flex items-start gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5"
|
||||
>
|
||||
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||
{ev.time ?? '終日'}
|
||||
{fmtTimeBadge(ev)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
|
||||
@@ -511,7 +458,7 @@ function DayPanel({
|
||||
<div className="text-[10px] font-medium text-amber-700 dark:text-amber-300">🗓 {fmtRange(ev)}</div>
|
||||
)}
|
||||
{ev.description && <div className="truncate text-[11px] text-slate-500">{ev.description}</div>}
|
||||
{ev.createdBy === 'agent' && <div className="text-[10px] text-slate-400">🤖 エージェント</div>}
|
||||
{ev.createdBy === 'agent' && <div className="text-[10px] text-slate-400">{t('calendar.day.agent')}</div>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -520,7 +467,7 @@ function DayPanel({
|
||||
data-testid={`space-cal-event-edit-${ev.id}`}
|
||||
onClick={() => { setShowAdd(false); setEditing(ev); }}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-surface hover:text-slate-700"
|
||||
aria-label="編集"
|
||||
aria-label={t('calendar.day.editEvent')}
|
||||
>
|
||||
<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 2l3 3-8 8H3v-3z" /></svg>
|
||||
</button>
|
||||
@@ -528,12 +475,12 @@ function DayPanel({
|
||||
type="button"
|
||||
data-testid={`space-cal-event-delete-${ev.id}`}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('この予定を削除しますか?')) return;
|
||||
if (!window.confirm(t('calendar.day.deleteEventConfirm'))) return;
|
||||
await deleteCalendarEvent(spaceId, ev.id);
|
||||
invalidate();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
aria-label="削除"
|
||||
aria-label={t('common:delete')}
|
||||
>
|
||||
<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="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" /></svg>
|
||||
</button>
|
||||
@@ -543,7 +490,7 @@ function DayPanel({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
!showAdd && <p className="text-xs text-slate-400">予定はありません。</p>
|
||||
!showAdd && <p className="text-xs text-slate-400">{t('calendar.day.noEvents')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -556,6 +503,7 @@ function DayPanel({
|
||||
imageSrc={preview.imageSrc}
|
||||
markdownImageBaseUrl={preview.markdownImageBaseUrl}
|
||||
trustedHtmlUrl={preview.trustedHtmlUrl}
|
||||
office={preview.office}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -576,17 +524,25 @@ function EventForm({
|
||||
onCancel: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [title, setTitle] = useState(event?.title ?? '');
|
||||
const [date, setDate] = useState(event?.date ?? defaultDate);
|
||||
const [endDate, setEndDate] = useState(event?.endDate ?? '');
|
||||
const [time, setTime] = useState(event?.time ?? '');
|
||||
const [endTime, setEndTime] = useState(event?.endTime ?? '');
|
||||
const [description, setDescription] = useState(event?.description ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!title.trim()) { setError('タイトルを入力してください。'); return; }
|
||||
if (endDate && endDate < date) { setError('終了日は開始日以降にしてください。'); return; }
|
||||
if (!title.trim()) { setError(t('calendar.form.titleRequired')); return; }
|
||||
if (endDate && endDate < date) { setError(t('calendar.form.endAfterStart')); return; }
|
||||
// 終了時刻は開始時刻があるときだけ。単日は開始以降のみ(複数日は終了日側の時刻なので順序不問)。
|
||||
const effEndTime = time && endTime ? endTime : null;
|
||||
const isMultiDay = !!(endDate && endDate > date);
|
||||
if (effEndTime && !isMultiDay && effEndTime < time) {
|
||||
setError(t('calendar.form.endTimeAfterStart')); return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -595,6 +551,7 @@ function EventForm({
|
||||
// 終了日が開始日より後のときだけ複数日。それ以外は単日(null)に正規化。
|
||||
endDate: endDate && endDate > date ? endDate : null,
|
||||
time: time ? time : null,
|
||||
endTime: effEndTime,
|
||||
title: title.trim(),
|
||||
description: description ? description : null,
|
||||
};
|
||||
@@ -602,11 +559,11 @@ function EventForm({
|
||||
else await createCalendarEvent(spaceId, payload);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? '保存に失敗しました。');
|
||||
setError((e as Error)?.message ?? t('calendar.form.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [title, date, endDate, time, description, event, spaceId, onSaved]);
|
||||
}, [title, date, endDate, time, endTime, description, event, spaceId, onSaved]);
|
||||
|
||||
return (
|
||||
<div data-testid="space-cal-add-event" className="mb-2 space-y-2 rounded-md border border-hairline bg-surface p-2.5">
|
||||
@@ -615,11 +572,11 @@ function EventForm({
|
||||
data-testid="space-cal-event-title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="予定のタイトル"
|
||||
placeholder={t('calendar.form.titlePlaceholder')}
|
||||
className="w-full rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">開始</label>
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.start')}</label>
|
||||
<input
|
||||
type="date"
|
||||
data-testid="space-cal-event-date"
|
||||
@@ -636,7 +593,7 @@ function EventForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">終了</label>
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.end')}</label>
|
||||
<input
|
||||
type="date"
|
||||
data-testid="space-cal-event-end-date"
|
||||
@@ -645,21 +602,30 @@ function EventForm({
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
className="flex-1 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
{endDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEndDate('')}
|
||||
className="shrink-0 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-2xs text-slate-500 hover:bg-surface"
|
||||
>
|
||||
単日に戻す
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="time"
|
||||
data-testid="space-cal-event-end-time"
|
||||
value={endTime}
|
||||
disabled={!time}
|
||||
title={!time ? t('calendar.form.endTimeHint') : undefined}
|
||||
onChange={e => setEndTime(e.target.value)}
|
||||
className="w-28 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{endDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEndDate('')}
|
||||
className="self-start rounded-md border border-hairline bg-canvas px-2 py-1 text-2xs text-slate-500 hover:bg-surface"
|
||||
>
|
||||
{t('calendar.form.backToSingleDay')}
|
||||
</button>
|
||||
)}
|
||||
<textarea
|
||||
data-testid="space-cal-event-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="メモ(任意)"
|
||||
placeholder={t('calendar.form.notePlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
@@ -671,7 +637,7 @@ function EventForm({
|
||||
disabled={saving}
|
||||
className="rounded-md border border-hairline bg-canvas px-2.5 py-1 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
キャンセル
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -680,7 +646,7 @@ function EventForm({
|
||||
disabled={saving}
|
||||
className="rounded-md bg-accent px-2.5 py-1 text-2xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{event ? '更新' : '追加'}
|
||||
{event ? t('calendar.form.update') : t('calendar.form.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+51
-29
@@ -1,41 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useCreateSpace } from '../../hooks/useSpaces';
|
||||
import { useCreateSpace, useUpdateSpace } from '../../hooks/useSpaces';
|
||||
import type { Space } from '../../api';
|
||||
|
||||
interface CreateSpaceDialogProps {
|
||||
interface SpaceFormDialogProps {
|
||||
/** 指定すると編集モード(名前・色・説明を更新)。未指定なら新規作成モード。 */
|
||||
space?: Space;
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
onSaved?: (id: string) => void;
|
||||
}
|
||||
|
||||
// DESIGN.md のブランド設定 UI に倣ったプリセット色。
|
||||
const PRESET_COLORS = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#64748b'];
|
||||
|
||||
export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps) {
|
||||
/**
|
||||
* ワークスペースの新規作成・編集を兼ねるダイアログ。`space` を渡すと編集モードに
|
||||
* なり、名前・ブランド色・説明をまとめて更新する(種類は変更不可なので出さない)。
|
||||
*/
|
||||
export function SpaceFormDialog({ space, onClose, onSaved }: SpaceFormDialogProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const isEdit = !!space;
|
||||
const createSpace = useCreateSpace();
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [brandColor, setBrandColor] = useState<string>(PRESET_COLORS[0]);
|
||||
const updateSpace = useUpdateSpace();
|
||||
const [title, setTitle] = useState(space?.title ?? '');
|
||||
const [description, setDescription] = useState(space?.description ?? '');
|
||||
const [brandColor, setBrandColor] = useState<string>(space?.brandColor ?? PRESET_COLORS[0]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submitting = createSpace.isPending;
|
||||
const submitting = createSpace.isPending || updateSpace.isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) {
|
||||
setError('ワークスペース名を入力してください。');
|
||||
setError(t('formDialog.error.nameRequired'));
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const space = await createSpace.mutateAsync({
|
||||
title: trimmed,
|
||||
description: description.trim() || undefined,
|
||||
brandColor: brandColor || null,
|
||||
});
|
||||
onCreated?.(space.id);
|
||||
if (isEdit) {
|
||||
const updated = await updateSpace.mutateAsync({
|
||||
id: space!.id,
|
||||
patch: { title: trimmed, description: description.trim(), brandColor: brandColor || null },
|
||||
});
|
||||
onSaved?.(updated.id);
|
||||
} else {
|
||||
const created = await createSpace.mutateAsync({
|
||||
title: trimmed,
|
||||
description: description.trim() || undefined,
|
||||
brandColor: brandColor || null,
|
||||
});
|
||||
onSaved?.(created.id);
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'ワークスペースを作成できませんでした。');
|
||||
setError(e instanceof Error ? e.message : isEdit ? t('formDialog.error.updateFailed') : t('formDialog.error.createFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,15 +70,17 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
|
||||
<div className="flex items-start justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
|
||||
新規ワークスペース
|
||||
{isEdit ? t('formDialog.title.edit') : t('formDialog.title.create')}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
|
||||
クライアントや案件ごとに、成果が蓄積する作業場所を作ります。
|
||||
{isEdit
|
||||
? t('formDialog.description.edit')
|
||||
: t('formDialog.description.create')}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
aria-label="閉じる"
|
||||
aria-label={t('formDialog.close')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 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="2" strokeLinecap="round">
|
||||
@@ -71,44 +92,45 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-slate-600">ワークスペース名<span className="text-red-500"> *</span></span>
|
||||
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.name')}<span className="text-red-500"> *</span></span>
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="space-title-input"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="例: ◯◯社 受託PJ"
|
||||
placeholder={t('formDialog.field.namePlaceholder')}
|
||||
className="rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-slate-600">説明(任意)</span>
|
||||
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.description')}</span>
|
||||
<textarea
|
||||
data-testid="space-description-input"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="このワークスペースで扱う案件の概要"
|
||||
placeholder={t('formDialog.field.descriptionPlaceholder')}
|
||||
className="resize-none rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-semibold text-slate-600">ブランド色</span>
|
||||
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.brandColor')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setBrandColor(c)}
|
||||
aria-label={`色 ${c}`}
|
||||
aria-label={t('formDialog.colorSwatch', { color: c })}
|
||||
className={`h-6 w-6 rounded-full transition-transform ${
|
||||
brandColor === c ? 'ring-2 ring-offset-2 ring-[var(--brand-primary)]' : ''
|
||||
}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
<label className="ml-1 flex cursor-pointer items-center" title="自由に選択">
|
||||
<label className="ml-1 flex cursor-pointer items-center" title={t('formDialog.customColor')}>
|
||||
<input
|
||||
type="color"
|
||||
value={brandColor}
|
||||
@@ -127,17 +149,17 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
|
||||
type="button"
|
||||
className="rounded-md border border-hairline px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
キャンセル
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-space-submit"
|
||||
data-testid="space-form-submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '作成中…' : '作成'}
|
||||
{submitting ? (isEdit ? t('formDialog.saving') : t('formDialog.creating')) : (isEdit ? t('common:save') : t('formDialog.createButton'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,6 +16,7 @@
|
||||
* 「認証を有効化するとスペースを共有できます」の案内を出す。
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceMembers,
|
||||
@@ -35,12 +36,6 @@ import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
const ROLE_LABEL: Record<SpaceMemberRole, string> = {
|
||||
owner: 'オーナー',
|
||||
editor: '編集者',
|
||||
viewer: '閲覧者',
|
||||
};
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
@@ -58,6 +53,7 @@ function Avatar({ url, name }: { url: string | null; name: string | null }) {
|
||||
}
|
||||
|
||||
export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -82,24 +78,24 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
mutationFn: ({ userId, role }: { userId: string; role: SpaceMemberRole }) =>
|
||||
updateSpaceMemberRole(spaceId, userId, role),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => showToast?.(`ロールの変更に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.roleChangeFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (userId: string) => removeSpaceMember(spaceId, userId),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => showToast?.(`メンバーの除去に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.removeFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: (input: { userId: string; role: SpaceMemberRole }) => addSpaceMember(spaceId, input),
|
||||
onSuccess: () => { invalidate(); setPicking(false); },
|
||||
onError: (e) => showToast?.(`メンバーの追加に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.addFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const handleRemove = (m: SpaceMember) => {
|
||||
const who = m.name ?? m.email ?? m.userId;
|
||||
if (window.confirm(`${who} をこのワークスペースから除去しますか?`)) {
|
||||
if (window.confirm(t('members.removeConfirm', { who }))) {
|
||||
removeMut.mutate(m.userId);
|
||||
}
|
||||
};
|
||||
@@ -108,15 +104,15 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
<div className="h-full overflow-y-auto" data-testid="space-members-panel">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">メンバー</h2>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('members.heading')}</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
このワークスペースを共有しているメンバーです。編集者はタスク・ファイル・カレンダーを編集でき、閲覧者は閲覧のみ可能です。
|
||||
{t('members.intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-[13px] text-slate-400">読み込み中…</div>}
|
||||
{isLoading && <div className="text-[13px] text-slate-400">{t('common:loading')}</div>}
|
||||
{isError && (
|
||||
<div className="text-[13px] text-red-600">メンバーの取得に失敗しました: {errMsg(error)}</div>
|
||||
<div className="text-[13px] text-red-600">{t('members.fetchError', { msg: errMsg(error) })}</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
@@ -135,7 +131,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
</span>
|
||||
{m.isOwner && (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 dark:bg-blue-500/15 text-blue-600 dark:text-blue-300 leading-none">
|
||||
オーナー
|
||||
{t('members.role.owner')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -154,8 +150,8 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
}
|
||||
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
<option value="editor">{ROLE_LABEL.editor}</option>
|
||||
<option value="viewer">{ROLE_LABEL.viewer}</option>
|
||||
<option value="editor">{t('members.role.editor')}</option>
|
||||
<option value="viewer">{t('members.role.viewer')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,7 +160,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
disabled={removeMut.isPending}
|
||||
className="text-2xs text-red-600 hover:text-red-800 dark:hover:text-red-300 underline disabled:opacity-50"
|
||||
>
|
||||
除去
|
||||
{t('members.remove')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -172,7 +168,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
data-testid={`space-member-role-${m.userId}`}
|
||||
className="text-2xs text-slate-500"
|
||||
>
|
||||
{ROLE_LABEL[m.role]}
|
||||
{t(`members.role.${m.role}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -204,7 +200,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
onClick={() => setPicking(true)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold text-accent border border-accent/30 hover:bg-accent-soft transition-colors"
|
||||
>
|
||||
メンバーを招待
|
||||
{t('members.inviteButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -214,15 +210,10 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
);
|
||||
}
|
||||
|
||||
const INVITE_ROLE_LABEL: Record<SpaceInviteRole, string> = {
|
||||
editor: '編集者',
|
||||
viewer: '閲覧者',
|
||||
};
|
||||
|
||||
const EXPIRY_OPTIONS: Array<{ label: string; days: number | null }> = [
|
||||
{ label: '無期限', days: null },
|
||||
{ label: '7日', days: 7 },
|
||||
{ label: '30日', days: 30 },
|
||||
const EXPIRY_OPTIONS: Array<{ labelKey: string; days: number | null }> = [
|
||||
{ labelKey: 'members.invite.expiry.never', days: null },
|
||||
{ labelKey: 'members.invite.expiry.days7', days: 7 },
|
||||
{ labelKey: 'members.invite.expiry.days30', days: 30 },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -231,6 +222,7 @@ const EXPIRY_OPTIONS: Array<{ label: string; days: number | null }> = [
|
||||
* 組織(pickable の絞り込み)に依存しない招待経路になる。
|
||||
*/
|
||||
function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const [role, setRole] = useState<SpaceInviteRole>('viewer');
|
||||
const [expiryIdx, setExpiryIdx] = useState(0);
|
||||
@@ -247,13 +239,13 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createSpaceInvite(spaceId, { role, expiresInDays: EXPIRY_OPTIONS[expiryIdx].days }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => showToast?.(`招待リンクの作成に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.inviteCreateFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: () => revokeSpaceInvite(spaceId),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => showToast?.(`招待リンクの無効化に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.inviteRevokeFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const absoluteUrl = invite ? `${window.location.origin}${invite.url}` : '';
|
||||
@@ -265,21 +257,21 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
showToast?.('コピーに失敗しました。手動で選択してください。', 'error');
|
||||
showToast?.(t('members.toast.copyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="space-invite-section" className="rounded-md border border-hairline bg-surface/40 p-4 space-y-3 max-w-md">
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold text-slate-900">招待リンク</h3>
|
||||
<h3 className="text-[13px] font-semibold text-slate-900">{t('members.invite.title')}</h3>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed mt-0.5">
|
||||
リンクを知っている人が、ログインのうえ選んだ役割でこのワークスペースに参加できます。組織に所属していない相手も招待できます。
|
||||
{t('members.invite.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-[13px] text-slate-400">読み込み中…</div>
|
||||
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
|
||||
) : active ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -296,13 +288,15 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
onClick={handleCopy}
|
||||
className="h-8 shrink-0 rounded-md bg-accent px-3 text-xs font-semibold text-accent-fg hover:bg-accent-deep"
|
||||
>
|
||||
{copied ? 'コピー済' : 'コピー'}
|
||||
{copied ? t('members.invite.copied') : t('members.invite.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-2xs text-slate-500">
|
||||
<span>
|
||||
役割: {INVITE_ROLE_LABEL[invite.role]}
|
||||
{invite.expiresAt ? ` ・ 期限: ${new Date(invite.expiresAt.replace(' ', 'T') + 'Z').toLocaleDateString()}` : ' ・ 無期限'}
|
||||
{t('members.invite.roleLabel', { role: t(`members.role.${invite.role}`) })}
|
||||
{invite.expiresAt
|
||||
? t('members.invite.expiresAt', { date: new Date(invite.expiresAt.replace(' ', 'T') + 'Z').toLocaleDateString() })
|
||||
: t('members.invite.noExpiry')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -312,7 +306,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
disabled={createMut.isPending}
|
||||
className="text-slate-600 underline hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
再生成
|
||||
{t('members.invite.regenerate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -321,7 +315,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
disabled={revokeMut.isPending}
|
||||
className="text-red-600 underline hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
無効化
|
||||
{t('members.invite.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,8 +329,8 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
onChange={(e) => setRole(e.target.value as SpaceInviteRole)}
|
||||
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
<option value="viewer">{INVITE_ROLE_LABEL.viewer}</option>
|
||||
<option value="editor">{INVITE_ROLE_LABEL.editor}</option>
|
||||
<option value="viewer">{t('members.role.viewer')}</option>
|
||||
<option value="editor">{t('members.role.editor')}</option>
|
||||
</select>
|
||||
<select
|
||||
data-testid="space-invite-expiry"
|
||||
@@ -345,7 +339,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((o, i) => (
|
||||
<option key={i} value={i}>{o.label}</option>
|
||||
<option key={i} value={i}>{t(o.labelKey)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
@@ -355,7 +349,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
disabled={createMut.isPending}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{createMut.isPending ? '作成中…' : '招待リンクを作成'}
|
||||
{createMut.isPending ? t('members.invite.creating') : t('members.invite.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,6 +371,7 @@ function InvitePicker({
|
||||
onCancel: () => void;
|
||||
onAdd: (userId: string, role: SpaceMemberRole) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['pickable-users', spaceId],
|
||||
queryFn: fetchPickableUsers,
|
||||
@@ -409,14 +404,14 @@ function InvitePicker({
|
||||
data-testid="space-member-picker"
|
||||
className="rounded-md border border-hairline bg-surface/50 p-4 text-[13px] text-slate-500"
|
||||
>
|
||||
認証を有効化するとワークスペースを共有できます。
|
||||
{t('members.picker.authRequired')}
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs text-slate-600 hover:text-slate-800 underline"
|
||||
>
|
||||
閉じる
|
||||
{t('members.picker.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -432,23 +427,23 @@ function InvitePicker({
|
||||
className="rounded-md border border-hairline bg-surface/50 p-4 space-y-3 max-w-md"
|
||||
>
|
||||
<p data-testid="space-member-picker-org-note" className="text-2xs text-slate-500 leading-relaxed">
|
||||
同じ組織のメンバーのみ表示されます。
|
||||
{t('members.picker.orgNote')}
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="名前・メールで検索"
|
||||
placeholder={t('members.picker.searchPlaceholder')}
|
||||
className="h-8 w-full rounded-md border border-hairline px-2 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-[13px] text-slate-400">読み込み中…</div>
|
||||
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="text-[13px] text-slate-400">
|
||||
{allAlreadyAdded
|
||||
? '同じ組織のメンバーは全員このワークスペースに追加済みです。'
|
||||
: '追加できるユーザーがいません。同じ組織のメンバーだけが候補に表示されます。'}
|
||||
? t('members.picker.allAdded')
|
||||
: t('members.picker.noCandidates')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
@@ -480,8 +475,8 @@ function InvitePicker({
|
||||
onChange={(e) => setRole(e.target.value as SpaceMemberRole)}
|
||||
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
<option value="editor">{ROLE_LABEL.editor}</option>
|
||||
<option value="viewer">{ROLE_LABEL.viewer}</option>
|
||||
<option value="editor">{t('members.role.editor')}</option>
|
||||
<option value="viewer">{t('members.role.viewer')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
@@ -489,14 +484,14 @@ function InvitePicker({
|
||||
onClick={() => selectedId && onAdd(selectedId, role)}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '追加中…' : '追加'}
|
||||
{isPending ? t('members.picker.adding') : t('members.picker.add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-1.5 rounded-md text-xs text-slate-700 border border-hairline hover:bg-surface transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component test for SpaceRail — the workspace switcher rail. Mocks the data
|
||||
* hooks (useSpaces, useLocalTaskList) and useAuthState so the real grouping,
|
||||
* "自分" badge, running-count badge, selection, empty/loading/error states and
|
||||
* the create dialog toggle are exercised. SpaceFormDialog is stubbed so opening
|
||||
* it doesn't pull in the full create-dialog tree.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import type { Space } from '../../api';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
// --- Mocks (declared before importing the component under test) -------------
|
||||
const useSpacesMock = vi.fn();
|
||||
const useTaskListMock = vi.fn();
|
||||
const useAuthStateMock = vi.fn();
|
||||
|
||||
vi.mock('../../hooks/useSpaces', () => ({ useSpaces: () => useSpacesMock() }));
|
||||
vi.mock('../../hooks/useTaskList', () => ({ useLocalTaskList: () => useTaskListMock() }));
|
||||
vi.mock('../../App', () => ({ useAuthState: () => useAuthStateMock() }));
|
||||
vi.mock('./SpaceFormDialog', () => ({
|
||||
SpaceFormDialog: ({ onSaved }: { onSaved: (id: string) => void }) => (
|
||||
<div data-testid="space-form-dialog">
|
||||
<button onClick={() => onSaved('new-space')}>save</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { SpaceRail } from './SpaceRail';
|
||||
|
||||
function space(p: Partial<Space> & Pick<Space, 'id' | 'kind' | 'title'>): Space {
|
||||
return {
|
||||
description: '',
|
||||
ownerId: null,
|
||||
visibility: 'private',
|
||||
visibilityScopeOrgId: null,
|
||||
status: 'open',
|
||||
brandColor: null,
|
||||
workspaceDir: null,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...p,
|
||||
} as Space;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Rail labels (loading/error/empty/running-count/"自分" badge) now route through
|
||||
// i18next. Pin the language so text assertions are deterministic.
|
||||
void i18n.changeLanguage('ja');
|
||||
useSpacesMock.mockReset();
|
||||
useTaskListMock.mockReset();
|
||||
useAuthStateMock.mockReset();
|
||||
useTaskListMock.mockReturnValue({ data: [] });
|
||||
useAuthStateMock.mockReturnValue({ mode: 'disabled' });
|
||||
});
|
||||
|
||||
describe('SpaceRail', () => {
|
||||
it('shows the loading state', () => {
|
||||
useSpacesMock.mockReturnValue({ data: undefined, isLoading: true, isError: false });
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
expect(screen.getByText('読み込み中…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the error state', () => {
|
||||
useSpacesMock.mockReturnValue({ data: undefined, isLoading: false, isError: true });
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
expect(screen.getByText('ワークスペースを取得できませんでした')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no spaces', () => {
|
||||
useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false });
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
expect(
|
||||
screen.getByText('ワークスペースがありません。「+ 新規」から作成してください。'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups personal and case spaces under their headers', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [
|
||||
space({ id: 'p1', kind: 'personal', title: 'My Workspace' }),
|
||||
space({ id: 'c1', kind: 'case', title: 'Project A' }),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
const groups = screen.getAllByTestId('space-group');
|
||||
expect(groups.map((g) => g.getAttribute('data-group'))).toEqual([
|
||||
'統合スペース',
|
||||
'個別スペース',
|
||||
]);
|
||||
expect(screen.getByText('My Workspace')).toBeInTheDocument();
|
||||
expect(screen.getByText('Project A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelect with the space id when a row is clicked', () => {
|
||||
const onSelect = vi.fn();
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={onSelect} />);
|
||||
fireEvent.click(screen.getByText('Project A'));
|
||||
expect(onSelect).toHaveBeenCalledWith('c1');
|
||||
});
|
||||
|
||||
it('renders a running-count badge when a space has running tasks', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
useTaskListMock.mockReturnValue({
|
||||
data: [
|
||||
{ spaceId: 'c1', latestJob: { status: 'running' } },
|
||||
{ spaceId: 'c1', latestJob: { status: 'succeeded' } },
|
||||
],
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
const badge = screen.getByTestId('space-running-count');
|
||||
expect(badge).toHaveTextContent('1 実行中');
|
||||
});
|
||||
|
||||
it('shows the "自分" badge only when other users\' spaces are present (admin view)', () => {
|
||||
useAuthStateMock.mockReturnValue({ mode: 'authenticated', user: { id: 'me' } });
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [
|
||||
space({ id: 'c1', kind: 'case', title: 'Mine', ownerId: 'me' }),
|
||||
space({ id: 'c2', kind: 'case', title: 'Theirs', ownerId: 'other' }),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
const badges = screen.getAllByTestId('space-mine-badge');
|
||||
expect(badges).toHaveLength(1);
|
||||
// Badge lives inside the owner's row.
|
||||
const mineRow = screen.getByText('Mine').closest('[data-testid="space-row"]');
|
||||
expect(mineRow).toHaveAttribute('data-space-mine', '1');
|
||||
});
|
||||
|
||||
it('hides the "自分" badge when all spaces belong to the viewer', () => {
|
||||
useAuthStateMock.mockReturnValue({ mode: 'authenticated', user: { id: 'me' } });
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Mine', ownerId: 'me' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
expect(screen.queryByTestId('space-mine-badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the create dialog and selects the new space on save', () => {
|
||||
const onSelect = vi.fn();
|
||||
useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false });
|
||||
render(<SpaceRail onSelect={onSelect} />);
|
||||
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('create-space-btn'));
|
||||
expect(screen.getByTestId('space-form-dialog')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(onSelect).toHaveBeenCalledWith('new-space');
|
||||
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +1,116 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthState } from '../../App';
|
||||
import { useSpaces } from '../../hooks/useSpaces';
|
||||
import { useLocalTaskList } from '../../hooks/useTaskList';
|
||||
import { sortSpacesForRail } from '../../lib/spaceSort';
|
||||
import { countRunningTasksForSpace } from '../../lib/spaceTasks';
|
||||
import { statusTone } from '../../lib/utils';
|
||||
import type { Space } from '../../api';
|
||||
import { CreateSpaceDialog } from './CreateSpaceDialog';
|
||||
import { SpaceFormDialog } from './SpaceFormDialog';
|
||||
|
||||
interface SpaceRailProps {
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const VIS_LABEL: Record<Space['visibility'], string> = {
|
||||
private: 'private',
|
||||
// 可視性ラベルは private を出さない(既定値でノイズになるため)。org/public のみ
|
||||
// 意味があるので表示する。
|
||||
const VIS_LABEL: Partial<Record<Space['visibility'], string>> = {
|
||||
org: 'org',
|
||||
public: 'public',
|
||||
};
|
||||
|
||||
// グループの色帯。統合スペース(個人=作業が集まる中心)はブランド色、個別スペース
|
||||
// (案件=プロジェクトごとに分かれる)は中立色で、左端の帯で一目で区別する。
|
||||
const GROUP_BAND_INTEGRATED = 'var(--brand-primary)';
|
||||
const GROUP_BAND_INDIVIDUAL = '#94a3b8'; // slate-400
|
||||
|
||||
interface SpaceGroupDef {
|
||||
/** Stable key used for data-group (test selector); decoupled from the display label. */
|
||||
key: string;
|
||||
label: string;
|
||||
band: string;
|
||||
spaces: Space[];
|
||||
}
|
||||
|
||||
export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const { data: spaces, isLoading, isError } = useSpaces();
|
||||
// 実行中件数の算出元。リスト API はスペースで絞らないので全件を保持しており、
|
||||
// FAST ポーリングで自動更新される。スペースごとにクライアント側で数える。
|
||||
const { data: tasks } = useLocalTaskList();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const auth = useAuthState();
|
||||
const myUserId = auth.mode === 'authenticated' ? auth.user.id : null;
|
||||
|
||||
const sorted = useMemo(() => sortSpacesForRail(spaces ?? []), [spaces]);
|
||||
const personal = sorted.filter(s => s.kind === 'personal');
|
||||
const cases = sorted.filter(s => s.kind !== 'personal');
|
||||
// 他ユーザー所有のスペースが一覧に混在するとき(=admin が全ユーザーのスペースを
|
||||
// 見ている場合)だけ「自分」バッジを出す。単一ユーザーの一覧では全部自分なので
|
||||
// ノイズにしかならず、出さない(issue #003)。
|
||||
const hasOthersSpaces = useMemo(
|
||||
() => myUserId != null && sorted.some(s => s.ownerId != null && s.ownerId !== myUserId),
|
||||
[sorted, myUserId],
|
||||
);
|
||||
const groups = useMemo<SpaceGroupDef[]>(() => {
|
||||
const personal = sorted.filter(s => s.kind === 'personal');
|
||||
const cases = sorted.filter(s => s.kind !== 'personal');
|
||||
const out: SpaceGroupDef[] = [];
|
||||
if (personal.length > 0) out.push({ key: '統合スペース', label: t('rail.group.integrated'), band: GROUP_BAND_INTEGRATED, spaces: personal });
|
||||
if (cases.length > 0) out.push({ key: '個別スペース', label: t('rail.group.individual'), band: GROUP_BAND_INDIVIDUAL, spaces: cases });
|
||||
return out;
|
||||
}, [sorted, t]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" data-testid="space-rail">
|
||||
<div className="flex items-center justify-between border-b border-hairline px-3 py-2.5">
|
||||
<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('rail.heading')}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-space-btn"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="rounded-md border border-hairline px-2 py-1 text-xs font-semibold text-slate-700 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
+ 新規
|
||||
{t('rail.new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{isLoading && <p className="px-1 py-2 text-xs text-slate-500">読み込み中…</p>}
|
||||
{isError && <p className="px-1 py-2 text-xs text-red-600">ワークスペースを取得できませんでした</p>}
|
||||
{isLoading && <p className="px-1 py-2 text-xs text-slate-500">{t('common:loading')}</p>}
|
||||
{isError && <p className="px-1 py-2 text-xs text-red-600">{t('rail.fetchError')}</p>}
|
||||
|
||||
{personal.length > 0 && (
|
||||
<div className="mb-1 px-1 pt-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">個人</div>
|
||||
)}
|
||||
{personal.map(s => (
|
||||
<SpaceRow key={s.id} space={s} active={s.id === selectedId} onSelect={onSelect} />
|
||||
))}
|
||||
|
||||
{cases.length > 0 && (
|
||||
<div className="mb-1 mt-2 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">案件</div>
|
||||
)}
|
||||
{cases.map(s => (
|
||||
<SpaceRow key={s.id} space={s} active={s.id === selectedId} onSelect={onSelect} />
|
||||
{groups.map((g, i) => (
|
||||
<section
|
||||
key={g.key}
|
||||
data-testid="space-group"
|
||||
data-group={g.key}
|
||||
className={`border-l-2 pl-2 ${i > 0 ? 'mt-2 border-t border-hairline pt-2' : ''}`}
|
||||
style={{ borderLeftColor: g.band }}
|
||||
>
|
||||
<div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">{g.label}</div>
|
||||
{g.spaces.map(s => (
|
||||
<SpaceRow
|
||||
key={s.id}
|
||||
space={s}
|
||||
active={s.id === selectedId}
|
||||
onSelect={onSelect}
|
||||
runningCount={countRunningTasksForSpace(tasks ?? [], s)}
|
||||
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{!isLoading && !isError && sorted.length === 0 && (
|
||||
<p className="px-1 py-2 text-xs text-slate-500">ワークスペースがありません。「+ 新規」から作成してください。</p>
|
||||
<p className="px-1 py-2 text-xs text-slate-500">{t('rail.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateSpaceDialog
|
||||
<SpaceFormDialog
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={(id) => {
|
||||
onSaved={(id) => {
|
||||
setShowCreate(false);
|
||||
onSelect(id);
|
||||
}}
|
||||
@@ -77,18 +124,26 @@ function SpaceRow({
|
||||
space,
|
||||
active,
|
||||
onSelect,
|
||||
runningCount,
|
||||
mine,
|
||||
}: {
|
||||
space: Space;
|
||||
active: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
runningCount: number;
|
||||
/** 他ユーザーのスペースが混在する一覧で、これが閲覧者自身の所有なら true。 */
|
||||
mine?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const dot = space.brandColor ?? 'var(--brand-primary)';
|
||||
const runningStyle = statusTone('running');
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-row"
|
||||
data-space-kind={space.kind}
|
||||
data-space-id={space.id}
|
||||
data-space-mine={mine ? '1' : undefined}
|
||||
onClick={() => onSelect(space.id)}
|
||||
className={`mb-0.5 flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
@@ -102,9 +157,31 @@ function SpaceRow({
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1 truncate text-[12.5px] font-semibold text-slate-800">{space.title}</span>
|
||||
<span className="font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
|
||||
{VIS_LABEL[space.visibility]}
|
||||
</span>
|
||||
{mine && (
|
||||
<span
|
||||
data-testid="space-mine-badge"
|
||||
className="shrink-0 rounded-full bg-[var(--brand-primary-soft)] px-1.5 py-0.5 text-[9px] font-bold tracking-wide text-[var(--brand-primary)]"
|
||||
title={t('rail.mineTitle')}
|
||||
>
|
||||
{t('rail.mine')}
|
||||
</span>
|
||||
)}
|
||||
{VIS_LABEL[space.visibility] && (
|
||||
<span className="shrink-0 font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
|
||||
{VIS_LABEL[space.visibility]}
|
||||
</span>
|
||||
)}
|
||||
{runningCount > 0 && (
|
||||
<span
|
||||
data-testid="space-running-count"
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold tabular-nums"
|
||||
style={{ background: runningStyle.bg, color: runningStyle.fg }}
|
||||
title={t('rail.runningTitle', { count: runningCount })}
|
||||
aria-label={t('rail.runningAria', { count: runningCount })}
|
||||
>
|
||||
● {t('rail.running', { count: runningCount })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AgentsMdPanel } from '../userfolder/AgentsMdPanel';
|
||||
import { MemoryPanel } from '../userfolder/MemoryPanel';
|
||||
@@ -20,6 +21,7 @@ import { McpPanel } from '../userfolder/McpPanel';
|
||||
import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
|
||||
import { SpaceMembersPanel } from './SpaceMembersPanel';
|
||||
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
|
||||
import { SpaceToolSettings } from './SpaceToolSettings';
|
||||
import { PieceEditor } from '../settings/PieceEditor';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { splitPieces } from '../../lib/splitPieces';
|
||||
@@ -28,27 +30,29 @@ import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members';
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools';
|
||||
|
||||
const SECTIONS: { id: SettingsSection; label: string; testid: string }[] = [
|
||||
{ id: 'agents', label: 'AGENTS.md', testid: 'space-settings-nav-agents' },
|
||||
{ id: 'memory', label: 'メモリ', testid: 'space-settings-nav-memory' },
|
||||
{ id: 'pieces', label: 'Pieces', testid: 'space-settings-nav-pieces' },
|
||||
{ id: 'skills', label: 'スキル', testid: 'space-settings-nav-skills' },
|
||||
{ id: 'mcp', label: 'MCP', testid: 'space-settings-nav-mcp' },
|
||||
{ id: 'ssh', label: 'SSH', testid: 'space-settings-nav-ssh' },
|
||||
{ id: 'browser', label: 'ブラウザ', testid: 'space-settings-nav-browser' },
|
||||
{ id: 'members', label: 'メンバー', testid: 'space-settings-nav-members' },
|
||||
const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
|
||||
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' },
|
||||
{ id: 'memory', labelKey: 'settings.nav.memory', testid: 'space-settings-nav-memory' },
|
||||
{ id: 'pieces', labelKey: 'settings.nav.pieces', testid: 'space-settings-nav-pieces' },
|
||||
{ id: 'skills', labelKey: 'settings.nav.skills', testid: 'space-settings-nav-skills' },
|
||||
{ id: 'mcp', labelKey: 'settings.nav.mcp', testid: 'space-settings-nav-mcp' },
|
||||
{ id: 'ssh', labelKey: 'settings.nav.ssh', testid: 'space-settings-nav-ssh' },
|
||||
{ id: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' },
|
||||
{ id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' },
|
||||
{ id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' },
|
||||
];
|
||||
|
||||
export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [section, setSection] = useState<SettingsSection>('agents');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col md:flex-row md:gap-3">
|
||||
{/* Sub-nav: モバイルは横スクロールのセグメント、md+ は左の縦リスト。 */}
|
||||
<nav
|
||||
aria-label="ワークスペース設定"
|
||||
aria-label={t('settings.navLabel')}
|
||||
className="flex shrink-0 gap-1 overflow-x-auto border-b border-hairline pb-2 md:w-44 md:flex-col md:overflow-x-visible md:border-b-0 md:border-r md:pb-0 md:pr-3"
|
||||
>
|
||||
{SECTIONS.map(s => {
|
||||
@@ -65,7 +69,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
|
||||
: 'text-slate-600 hover:bg-surface hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
{t(s.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -80,6 +84,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
|
||||
{section === 'mcp' && <McpPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'ssh' && <SshConnectionsPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +97,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
|
||||
* `PieceEditor` の再利用で構成する。選択はローカル state。
|
||||
*/
|
||||
function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const isAdmin = auth.mode === 'authenticated' && auth.user.role === 'admin';
|
||||
const qc = useQueryClient();
|
||||
@@ -129,7 +135,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
setNewName('');
|
||||
setSelected({ name, source });
|
||||
} catch (e) {
|
||||
const msg = `Piece の作成に失敗しました: ${e instanceof Error ? e.message : String(e)}`;
|
||||
const msg = t('settings.pieces.createFailed', { msg: e instanceof Error ? e.message : String(e) });
|
||||
if (showToast) showToast(msg, 'error');
|
||||
else console.error(msg);
|
||||
} finally {
|
||||
@@ -158,16 +164,16 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* 左: 一覧 */}
|
||||
<div className="w-48 shrink-0 overflow-y-auto border-r border-hairline p-2">
|
||||
<div className="mb-1 px-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">Default</div>
|
||||
{defaults.length === 0 && <div className="px-2 text-xs text-slate-400">(なし)</div>}
|
||||
<div className="mb-1 px-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">{t('settings.pieces.default')}</div>
|
||||
{defaults.length === 0 && <div className="px-2 text-xs text-slate-400">{t('settings.pieces.none')}</div>}
|
||||
{defaults.map(p => renderRow(p, true))}
|
||||
|
||||
<div className="mb-1 mt-3 flex items-center justify-between px-2">
|
||||
<span className="text-2xs font-semibold uppercase tracking-wide text-slate-500">Custom</span>
|
||||
<span className="text-2xs font-semibold uppercase tracking-wide text-slate-500">{t('settings.pieces.custom')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreating(true)}
|
||||
title="新しい Piece"
|
||||
title={t('settings.pieces.new')}
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-slate-500 hover:bg-surface-2 hover:text-slate-900 text-sm leading-none transition-colors"
|
||||
>
|
||||
+
|
||||
@@ -189,7 +195,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{customs.length === 0 && !isCreating && <div className="px-2 text-xs text-slate-400">(なし)</div>}
|
||||
{customs.length === 0 && !isCreating && <div className="px-2 text-xs text-slate-400">{t('settings.pieces.none')}</div>}
|
||||
{customs.map(p => renderRow(p, false))}
|
||||
</div>
|
||||
|
||||
@@ -204,7 +210,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
onDeleted={() => setSelected(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-slate-400">左から Piece を選んでください。</div>
|
||||
<div className="text-sm text-slate-400">{t('settings.pieces.selectHint')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for SpaceToolSettings (tool-policy / sensitive-tools UI).
|
||||
*
|
||||
* This is the PR #653 bug class: a sensitive *tool* (Bash) is delivered in a
|
||||
* separate `sensitiveTools` array, NOT in `categories`. The save patch
|
||||
* (buildPolicyPatch) must fold Bash's toggle into `enabledSensitive`, alongside
|
||||
* sensitive *categories* (ssh / browser). These tests render the real component
|
||||
* and assert that toggling Bash persists through to the PUT payload — the exact
|
||||
* regression that the earlier "categories-only" patch builder dropped.
|
||||
*
|
||||
* api + App.useAuthState are mocked so no real network and canManage=true.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
const { fetchSpaceToolPolicyMock, updateSpaceToolPolicyMock, fetchSpaceMembersMock } = vi.hoisted(() => ({
|
||||
fetchSpaceToolPolicyMock: vi.fn(),
|
||||
updateSpaceToolPolicyMock: vi.fn(),
|
||||
fetchSpaceMembersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
fetchSpaceToolPolicy: fetchSpaceToolPolicyMock,
|
||||
updateSpaceToolPolicy: updateSpaceToolPolicyMock,
|
||||
fetchSpaceMembers: fetchSpaceMembersMock,
|
||||
}));
|
||||
|
||||
// auth.mode === 'disabled' => canManage = true (no owner check needed).
|
||||
vi.mock('../../App', () => ({
|
||||
useAuthState: () => ({ mode: 'disabled' as const }),
|
||||
}));
|
||||
|
||||
import { SpaceToolSettings } from './SpaceToolSettings';
|
||||
|
||||
const POLICY = {
|
||||
policy: { disabledSafe: [], enabledSensitive: [] },
|
||||
categories: [
|
||||
{ name: 'web', sensitive: false, enabled: true },
|
||||
{ name: 'office', sensitive: false, enabled: true },
|
||||
{ name: 'ssh', sensitive: true, enabled: false },
|
||||
{ name: 'browser', sensitive: true, enabled: false },
|
||||
],
|
||||
// Bash arrives as a SEPARATE sensitive tool, not a category — the PR #653 trap.
|
||||
sensitiveTools: [{ name: 'Bash', enabled: false }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Labels like the Save button now route through i18next. Pin the language so
|
||||
// assertions on rendered text are deterministic regardless of detector state.
|
||||
void i18n.changeLanguage('ja');
|
||||
fetchSpaceMembersMock.mockResolvedValue([]);
|
||||
fetchSpaceToolPolicyMock.mockResolvedValue(structuredClone(POLICY));
|
||||
updateSpaceToolPolicyMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
async function renderLoaded() {
|
||||
renderWithProviders(<SpaceToolSettings spaceId="space-1" />);
|
||||
// Wait for the policy query to resolve and the Bash row to render.
|
||||
await waitFor(() => expect(screen.getByText('Bash')).toBeInTheDocument());
|
||||
}
|
||||
|
||||
/** Find the role=switch toggle for a row identified by its label text. */
|
||||
function switchFor(label: string): HTMLElement {
|
||||
// Walk up from the label until we reach an ancestor that also contains a switch.
|
||||
let el: HTMLElement | null = screen.getByText(label);
|
||||
while (el && el.parentElement) {
|
||||
el = el.parentElement;
|
||||
const sw = within(el).queryByRole('switch');
|
||||
if (sw) return sw;
|
||||
}
|
||||
throw new Error(`No switch found for row "${label}"`);
|
||||
}
|
||||
|
||||
describe('SpaceToolSettings', () => {
|
||||
it('renders safe categories, sensitive categories, and the separate Bash tool', async () => {
|
||||
await renderLoaded();
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByText('ssh')).toBeInTheDocument();
|
||||
expect(screen.getByText('browser')).toBeInTheDocument();
|
||||
// Bash is the separately-delivered sensitive tool.
|
||||
expect(screen.getByText('Bash')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Save is disabled until a toggle changes', async () => {
|
||||
await renderLoaded();
|
||||
const save = screen.getByRole('button', { name: '保存' });
|
||||
expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
it('persists the Bash toggle into enabledSensitive (PR #653 regression)', async () => {
|
||||
await renderLoaded();
|
||||
// Each ToggleRow is a role="switch". Find Bash's switch by walking from its label.
|
||||
await userEvent.click(switchFor('Bash'));
|
||||
|
||||
const save = screen.getByRole('button', { name: '保存' });
|
||||
expect(save).not.toBeDisabled();
|
||||
await userEvent.click(save);
|
||||
|
||||
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
|
||||
const [spaceId, patch] = updateSpaceToolPolicyMock.mock.calls[0];
|
||||
expect(spaceId).toBe('space-1');
|
||||
// The critical assertion: Bash must land in enabledSensitive, NOT be dropped.
|
||||
expect(patch.enabledSensitive).toContain('Bash');
|
||||
// No safe category was disabled.
|
||||
expect(patch.disabledSafe).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists a sensitive CATEGORY toggle (ssh) alongside the tool system', async () => {
|
||||
await renderLoaded();
|
||||
await userEvent.click(switchFor('ssh'));
|
||||
await userEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
|
||||
const [, patch] = updateSpaceToolPolicyMock.mock.calls[0];
|
||||
expect(patch.enabledSensitive).toContain('ssh');
|
||||
expect(patch.enabledSensitive).not.toContain('Bash'); // untouched stays off
|
||||
});
|
||||
|
||||
it('disabling a safe category lands in disabledSafe', async () => {
|
||||
await renderLoaded();
|
||||
await userEvent.click(switchFor('web')); // turn OFF (was on)
|
||||
await userEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
|
||||
const [, patch] = updateSpaceToolPolicyMock.mock.calls[0];
|
||||
expect(patch.disabledSafe).toContain('web');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* SpaceToolSettings.tsx — ワークスペースごとのツールポリシー設定 UI
|
||||
*
|
||||
* - 安全カテゴリ(sensitive=false): デフォルト ON のトグル群
|
||||
* - センシティブカテゴリ(sensitive=true)+ Bash: デフォルト OFF のトグル群。
|
||||
* 各項目に 1 行のリスク説明を表示。
|
||||
* - カテゴリ一覧は API から動的取得(ハードコードなし)。
|
||||
* - オーナーのみ編集可(canManage 判定は SpaceMembersPanel と同じシグナル)。
|
||||
* - 保存は PUT /api/local/spaces/:id/tool-policy(react-query mutation)。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceToolPolicy,
|
||||
fetchSpaceMembers,
|
||||
updateSpaceToolPolicy,
|
||||
type ToolCategory,
|
||||
} from '../../api';
|
||||
import { useAuthState } from '../../App';
|
||||
import { splitCategories, buildPolicyPatch, countEnabledCategories } from '../../lib/toolPolicy';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
/** センシティブカテゴリ・ツールに表示する 1 行のリスク説明の翻訳キー。 */
|
||||
const SENSITIVE_NOTE_KEYS: Record<string, string> = {
|
||||
ssh: 'tools.sensitiveNote.ssh',
|
||||
browser: 'tools.sensitiveNote.browser',
|
||||
Bash: 'tools.sensitiveNote.Bash',
|
||||
};
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
interface ToggleRowProps {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
note?: string;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function ToggleRow({ name, enabled, note, disabled, disabledReason, onChange }: ToggleRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2.5 border-b border-hairline last:border-b-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-slate-900">{name}</span>
|
||||
{disabled && disabledReason && (
|
||||
<span className="text-2xs text-slate-400">({disabledReason})</span>
|
||||
)}
|
||||
</div>
|
||||
{note && (
|
||||
<p className="text-2xs text-slate-500 leading-relaxed mt-0.5">{note}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-ring focus:ring-offset-1 disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
enabled ? 'bg-accent' : 'bg-slate-300 dark:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${
|
||||
enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpaceToolSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const noteFor = (name: string): string | undefined => {
|
||||
const key = SENSITIVE_NOTE_KEYS[name];
|
||||
return key ? t(key) : undefined;
|
||||
};
|
||||
|
||||
// メンバー一覧から canManage を判定(SpaceMembersPanel と同じロジック)
|
||||
const { data: members } = useQuery({
|
||||
queryKey: ['space-members', spaceId],
|
||||
queryFn: () => fetchSpaceMembers(spaceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const ownerRow = (members ?? []).find(m => m.isOwner);
|
||||
const canManage =
|
||||
auth.mode === 'disabled' ||
|
||||
(auth.mode === 'authenticated' &&
|
||||
(auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id)));
|
||||
|
||||
// ツールポリシー取得
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['space-tool-policy', spaceId],
|
||||
queryFn: () => fetchSpaceToolPolicy(spaceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// UI ローカルのトグル状態(未保存の変更を保持)
|
||||
const [toggledSafe, setToggledSafe] = useState<Record<string, boolean>>({});
|
||||
const [toggledSens, setToggledSens] = useState<Record<string, boolean>>({});
|
||||
const [savedState, setSavedState] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['space-tool-policy', spaceId] });
|
||||
};
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (patch: { disabledSafe: string[]; enabledSensitive: string[] }) =>
|
||||
updateSpaceToolPolicy(spaceId, patch),
|
||||
onSuccess: () => {
|
||||
setSavedState('saved');
|
||||
setToggledSafe({});
|
||||
setToggledSens({});
|
||||
invalidate();
|
||||
setTimeout(() => setSavedState('idle'), 2000);
|
||||
},
|
||||
onError: (e) => {
|
||||
setSavedState('error');
|
||||
showToast?.(t('tools.toast.saveFailed', { msg: errMsg(e) }), 'error');
|
||||
setTimeout(() => setSavedState('idle'), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (!data) return;
|
||||
const patch = buildPolicyPatch(data.categories, toggledSafe, toggledSens, data.sensitiveTools ?? []);
|
||||
saveMut.mutate(patch);
|
||||
};
|
||||
|
||||
const hasPendingChanges = Object.keys(toggledSafe).length > 0 || Object.keys(toggledSens).length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="text-[13px] text-red-600">
|
||||
{t('tools.fetchError', { msg: errMsg(error) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { safe, sensitive } = splitCategories(data.categories);
|
||||
const enabledCount = countEnabledCategories(data.categories, toggledSafe, toggledSens);
|
||||
const readonlyReason = t('tools.readonlyReason');
|
||||
|
||||
// センシティブグループ: カテゴリ + Bash(sensitiveTools から取得)
|
||||
// sensitiveTools は Bash など個別ツールで sensitive=true なもの
|
||||
const sensitiveBash = data.sensitiveTools ?? [];
|
||||
|
||||
const resolveEnabled = (cat: ToolCategory, map: Record<string, boolean>) => {
|
||||
return Object.prototype.hasOwnProperty.call(map, cat.name) ? map[cat.name] : cat.enabled;
|
||||
};
|
||||
|
||||
const resolveSensToolEnabled = (toolName: string, apiEnabled: boolean) => {
|
||||
return Object.prototype.hasOwnProperty.call(toggledSens, toolName)
|
||||
? toggledSens[toolName]
|
||||
: apiEnabled;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto" data-testid="space-tool-settings">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
|
||||
{/* ヘッダー */}
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('tools.heading')}</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
{t('tools.intro')}
|
||||
</p>
|
||||
<p className="text-[13px] text-slate-500 mt-1">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="tools.enabledCount"
|
||||
values={{ count: enabledCount }}
|
||||
components={{ strong: <span className="font-semibold text-slate-700" /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 安全カテゴリ(デフォルト ON) */}
|
||||
<section>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
|
||||
{t('tools.standardCategories')}
|
||||
</h3>
|
||||
{safe.length === 0 ? (
|
||||
<p className="text-[13px] text-slate-400">{t('tools.noStandardCategories')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden px-3">
|
||||
{safe.map(cat => (
|
||||
<ToggleRow
|
||||
key={cat.name}
|
||||
name={cat.name}
|
||||
enabled={resolveEnabled(cat, toggledSafe)}
|
||||
disabled={!canManage || saveMut.isPending}
|
||||
disabledReason={!canManage ? readonlyReason : undefined}
|
||||
onChange={v => setToggledSafe(prev => ({ ...prev, [cat.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* センシティブカテゴリ(デフォルト OFF) */}
|
||||
<section>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-1">
|
||||
{t('tools.sensitiveTools')}
|
||||
</h3>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed mb-2">
|
||||
{t('tools.sensitiveWarning')}
|
||||
</p>
|
||||
{sensitive.length === 0 && sensitiveBash.length === 0 ? (
|
||||
<p className="text-[13px] text-slate-400">{t('tools.noSensitiveCategories')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-hairline bg-amber-50/40 dark:bg-amber-900/10 divide-y divide-hairline overflow-hidden px-3">
|
||||
{/* センシティブカテゴリ(ssh / browser 等) */}
|
||||
{sensitive.map(cat => (
|
||||
<ToggleRow
|
||||
key={cat.name}
|
||||
name={cat.name}
|
||||
enabled={resolveEnabled(cat, toggledSens)}
|
||||
note={noteFor(cat.name)}
|
||||
disabled={!canManage || saveMut.isPending}
|
||||
disabledReason={!canManage ? readonlyReason : undefined}
|
||||
onChange={v => setToggledSens(prev => ({ ...prev, [cat.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
{/* 個別センシティブツール(Bash 等) */}
|
||||
{sensitiveBash.map(tool => (
|
||||
<ToggleRow
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
enabled={resolveSensToolEnabled(tool.name, tool.enabled)}
|
||||
note={noteFor(tool.name)}
|
||||
disabled={!canManage || saveMut.isPending}
|
||||
disabledReason={!canManage ? readonlyReason : undefined}
|
||||
onChange={v => setToggledSens(prev => ({ ...prev, [tool.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 保存ボタン */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canManage || saveMut.isPending || !hasPendingChanges}
|
||||
className="px-4 py-1.5 rounded-md text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saveMut.isPending ? t('tools.saving') : t('common:save')}
|
||||
</button>
|
||||
{savedState === 'saved' && (
|
||||
<span className="text-[13px] text-green-600">{t('tools.saved')}</span>
|
||||
)}
|
||||
{savedState === 'error' && (
|
||||
<span className="text-[13px] text-red-600">{t('tools.saveFailedInline')}</span>
|
||||
)}
|
||||
{!canManage && (
|
||||
<span className="text-[13px] text-slate-400">{readonlyReason}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SpaceRail } from './SpaceRail';
|
||||
import { SpaceDetail } from './SpaceDetail';
|
||||
import { SpaceDetail, type SpaceChatFilter } from './SpaceDetail';
|
||||
import { WorkerStatusWidget } from '../dashboard/WorkerStatusWidget';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
@@ -13,6 +14,8 @@ interface SpacesPageProps {
|
||||
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;
|
||||
}
|
||||
|
||||
// レール幅の許容範囲。狭すぎると一覧が読めず、広すぎると詳細を圧迫するため上下限でクランプ。
|
||||
@@ -25,7 +28,8 @@ function clampRailWidth(px: number): number {
|
||||
return Math.max(RAIL_MIN_PX, Math.min(RAIL_MAX_PX, Math.round(px)));
|
||||
}
|
||||
|
||||
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpacesPageProps) {
|
||||
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpacesPageProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const isMobile = useIsMobile();
|
||||
const [railWidth, setRailWidth] = useLocalStorageState<number>('maestro.spaceRailWidth', RAIL_DEFAULT_PX);
|
||||
const [collapsed, setCollapsed] = useLocalStorageState<boolean>('maestro.spaceRailCollapsed', false);
|
||||
@@ -49,13 +53,13 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
data-testid="space-rail-collapse"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-expanded={false}
|
||||
title="ワークスペース一覧を開く"
|
||||
title={t('page.expandRail')}
|
||||
className="hidden md:flex w-7 shrink-0 flex-col items-center justify-center gap-2 border-r border-hairline bg-surface text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider [writing-mode:vertical-rl]">ワークスペース</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider [writing-mode:vertical-rl]">{t('page.railLabel')}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
@@ -69,7 +73,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
data-testid="space-rail-collapse"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-expanded
|
||||
title="ワークスペース一覧を折りたたむ"
|
||||
title={t('page.collapseRail')}
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -105,7 +109,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
<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>
|
||||
ワークスペース一覧
|
||||
{t('page.railList')}
|
||||
</button>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
@@ -116,6 +120,8 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
onSelectSpaceTask={onSelectSpaceTask}
|
||||
onCreateTask={onCreateTask}
|
||||
onOpenTask={onOpenTask}
|
||||
chatFilter={chatFilter}
|
||||
onChatFilterChange={onChatFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,6 +133,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
// 親が clamp するので、ここでは絶対 X 座標からレール左端基準の幅を計算するだけ。
|
||||
// latest-ref パターンで drag 中に listener を貼り直さない。
|
||||
function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: number) => void }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const onResizeRef = useRef(onResize);
|
||||
onResizeRef.current = onResize;
|
||||
const draggingRef = useRef(false);
|
||||
@@ -172,7 +179,7 @@ function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: n
|
||||
ref={handleRef}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="ワークスペース一覧の幅を調整"
|
||||
aria-label={t('page.railResize')}
|
||||
data-testid="space-rail-resize"
|
||||
onPointerDown={handlePointerDown}
|
||||
className={`absolute top-0 right-0 z-10 h-full w-1.5 cursor-col-resize transition-colors hover:bg-slate-300/60 ${active ? 'bg-slate-400/60' : 'bg-transparent'}`}
|
||||
@@ -185,9 +192,11 @@ function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: n
|
||||
|
||||
// レール下部に常駐する折りたたみ式のワーカー/GPU 状況パネル。Tasks ページと同じ
|
||||
// WorkerStatusWidget を使い、空きスロット(=投入余地)を一目で確認できるようにする。
|
||||
// 既定は折りたたみで、レール本体の高さを圧迫しない。
|
||||
// 既定は折りたたみで、レール本体の高さを圧迫しない。開閉状態は localStorage に保存し、
|
||||
// リロード・再マウントをまたいで維持する(毎回閉じ直す手間をなくす)。
|
||||
function WorkerStatusFooter() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation('spaces');
|
||||
const [open, setOpen] = useLocalStorageState('space.workerStatus.open', false);
|
||||
return (
|
||||
<div data-testid="space-worker-status" className="shrink-0 border-t border-hairline bg-surface">
|
||||
<button
|
||||
@@ -197,7 +206,7 @@ function WorkerStatusFooter() {
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-[11px] font-bold uppercase tracking-wider text-slate-500 transition-colors hover:text-slate-700"
|
||||
>
|
||||
<span>ワーカー / GPU</span>
|
||||
<span>{t('page.workerGpu')}</span>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 16 16"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createSpaceGateway, createPublicAppGateway } from './app-file-gateway';
|
||||
|
||||
// read-only は writeFile/deleteFile の「不在」で表現する。認証版は両方を持ち、公開版は
|
||||
// 持たない。AppRunner はこの有無を見て read-only を判定する(プロパティ存在 = 書き込み可)。
|
||||
describe('app-file-gateway', () => {
|
||||
it('space gateway exposes write/delete (read-write)', () => {
|
||||
const g = createSpaceGateway('s1');
|
||||
expect(typeof g.writeFile).toBe('function');
|
||||
expect(typeof g.deleteFile).toBe('function');
|
||||
});
|
||||
|
||||
it('public app gateway has NO write/delete (read-only)', () => {
|
||||
const g = createPublicAppGateway('tok');
|
||||
expect(g.writeFile).toBeUndefined();
|
||||
expect(g.deleteFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rawUrl points at the matching backend endpoint for each gateway', () => {
|
||||
expect(createSpaceGateway('s1').rawUrl('apps/x/a.png')).toBe(
|
||||
'/api/local/spaces/s1/files/raw?path=apps%2Fx%2Fa.png',
|
||||
);
|
||||
expect(createPublicAppGateway('tok').rawUrl('apps/x/a.png')).toBe(
|
||||
'/api/app-share/tok/files/raw?path=apps%2Fx%2Fa.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// AppFileGateway — AppRunner のファイル I/O を抽象化する。
|
||||
//
|
||||
// AppRunner は同じサンドボックス iframe + postMessage ブリッジを、認証版(スペース
|
||||
// API・書き込み可)と公開版(app-share トークン API・read-only)の両方で使い回す。
|
||||
// その差分を吸収するのがこの gateway。
|
||||
//
|
||||
// READ-ONLY の表現:
|
||||
// read-only は writeFile/deleteFile プロパティの「不在」で表す(実行時 false を返す
|
||||
// メソッドではない)。AppRunner は `gateway.writeFile` が undefined かどうかで書き込み
|
||||
// 経路の有無を判定する。公開版は両メソッドを持たず、ブリッジが read-only エラーを返す。
|
||||
// サーバ側でも公開 API は GET のみなので、これは UI 側の二重防御に過ぎない。
|
||||
|
||||
import {
|
||||
fetchSpaceFileContent,
|
||||
fetchSpaceFiles,
|
||||
getSpaceFileRawUrl,
|
||||
writeSpaceFile,
|
||||
deleteSpaceFiles,
|
||||
fetchAppShareFileContent,
|
||||
fetchAppShareFiles,
|
||||
getAppShareRawUrl,
|
||||
type LocalFileEntry,
|
||||
} from '../../api';
|
||||
|
||||
export interface AppFileGateway {
|
||||
/** テキスト読み取り(workspace 相対パス)。 */
|
||||
fetchContent(path: string): Promise<string>;
|
||||
/** ディレクトリ一覧(workspace 相対パス)。 */
|
||||
listFiles(dir: string): Promise<{ entries: LocalFileEntry[] }>;
|
||||
/** raw アセット URL(rewriteRelativeAssets / img src 用)。 */
|
||||
rawUrl(path: string): string;
|
||||
/** 書き込み(read-only gateway では未定義)。 */
|
||||
writeFile?(path: string, content: string): Promise<{ ok: boolean }>;
|
||||
/** 削除(read-only gateway では未定義)。 */
|
||||
deleteFile?(path: string): Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
/** 認証版(スペース API)。write/delete あり。 */
|
||||
export function createSpaceGateway(spaceId: string): AppFileGateway {
|
||||
return {
|
||||
fetchContent: (path) => fetchSpaceFileContent(spaceId, path),
|
||||
listFiles: (dir) => fetchSpaceFiles(spaceId, dir),
|
||||
rawUrl: (path) => getSpaceFileRawUrl(spaceId, path),
|
||||
async writeFile(path, content) {
|
||||
await writeSpaceFile(spaceId, path, { content });
|
||||
return { ok: true };
|
||||
},
|
||||
async deleteFile(path) {
|
||||
await deleteSpaceFiles(spaceId, [path]);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 公開版(app-share トークン API)。read-only=write/delete は未定義。 */
|
||||
export function createPublicAppGateway(token: string): AppFileGateway {
|
||||
return {
|
||||
fetchContent: (path) => fetchAppShareFileContent(token, path),
|
||||
listFiles: (dir) => fetchAppShareFiles(token, dir),
|
||||
rawUrl: (path) => getAppShareRawUrl(token, path),
|
||||
// writeFile / deleteFile はあえて未定義(read-only)。
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildAppShareDisplayUrl } from './appShareUrl';
|
||||
|
||||
// shareUrl は相対パス(/ui/app/:token)。表示時に origin を前置するだけの純関数。
|
||||
describe('buildAppShareDisplayUrl', () => {
|
||||
it('prefixes the origin to a relative shareUrl', () => {
|
||||
expect(buildAppShareDisplayUrl('https://app.example.com', '/ui/app/abc')).toBe(
|
||||
'https://app.example.com/ui/app/abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double a trailing slash on the origin', () => {
|
||||
expect(buildAppShareDisplayUrl('https://app.example.com/', '/ui/app/abc')).toBe(
|
||||
'https://app.example.com/ui/app/abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes absolute shareUrls through unchanged', () => {
|
||||
expect(buildAppShareDisplayUrl('https://app.example.com', 'https://other/x')).toBe(
|
||||
'https://other/x',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// 公開アプリ共有リンクの表示 URL 組み立て(純関数)。
|
||||
//
|
||||
// サーバの share API は shareUrl を相対パス(/ui/app/:token)で返す。表示・コピーの
|
||||
// ためにブラウザの origin を前置する。既に絶対 URL(scheme 付き)ならそのまま返す。
|
||||
export function buildAppShareDisplayUrl(origin: string, shareUrl: string): string {
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(shareUrl)) return shareUrl; // already absolute
|
||||
return `${origin.replace(/\/+$/, '')}${shareUrl.startsWith('/') ? '' : '/'}${shareUrl}`;
|
||||
}
|
||||
Reference in New Issue
Block a user