This commit is contained in:
@@ -99,9 +99,9 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
{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)
|
||||
// ドットは「タスク or 予定」のある活動全般を示す(日詳細パネルの activeSpaces 判定と同じ基準)。
|
||||
const activeSpaceIds = bySpace
|
||||
? Object.keys(bySpace).filter(sid => bySpace[sid]!.taskCount > 0 || bySpace[sid]!.eventCount > 0)
|
||||
: [];
|
||||
const isToday = d === today;
|
||||
const isSelected = d === selectedDate;
|
||||
@@ -126,22 +126,29 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
>
|
||||
{Number(d.slice(8, 10))}
|
||||
</span>
|
||||
{taskSpaceIds.length > 0 && (
|
||||
{activeSpaceIds.length > 0 && (
|
||||
<span className="flex flex-wrap gap-0.5">
|
||||
{taskSpaceIds.slice(0, 6).map(sid => {
|
||||
{activeSpaceIds.slice(0, 6).map(sid => {
|
||||
const sp = spaceById.get(sid);
|
||||
const c = bySpace?.[sid];
|
||||
const detail = [
|
||||
c && c.taskCount > 0 ? t('calendar.taskCount', { count: c.taskCount }) : null,
|
||||
c && c.eventCount > 0 ? t('calendar.eventCount', { count: c.eventCount }) : null,
|
||||
]
|
||||
.filter((p): p is string => p !== null)
|
||||
.join(t('crossCalendar.detailSeparator'));
|
||||
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 })}
|
||||
title={t('crossCalendar.dotTitle', { name: sp?.name ?? sid, detail })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{taskSpaceIds.length > 6 && (
|
||||
<span className="text-[8px] font-bold text-slate-400">+{taskSpaceIds.length - 6}</span>
|
||||
{activeSpaceIds.length > 6 && (
|
||||
<span className="text-[8px] font-bold text-slate-400">+{activeSpaceIds.length - 6}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -183,9 +183,18 @@ 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={t('calendar.taskCount', { count: c.taskCount })}>
|
||||
💬{c.taskCount}
|
||||
{(filters.tasks && c?.taskCount) || (filters.events && c?.eventCount) ? (
|
||||
<span className="flex flex-wrap items-center gap-1 self-start">
|
||||
{filters.tasks && c?.taskCount ? (
|
||||
<span className="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}
|
||||
{filters.events && c?.eventCount ? (
|
||||
<span className="rounded bg-amber-100 px-1 text-[9px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300" title={t('calendar.eventCount', { count: c.eventCount })}>
|
||||
📌{c.eventCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
@@ -710,6 +710,12 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
};
|
||||
const handleSubtaskFilePreview = (tid: number, jobId: string, category: string, filePath: string) =>
|
||||
previewSubtaskFile(tid, jobId, category, filePath);
|
||||
// delegate カードの「変更ファイル」一覧クリック。filesChanged はワークスペース相対パス
|
||||
// (例: output/report.md) で、section='workspace' はタスクルート直下を指すため prefix strip 不要。
|
||||
const handleWorkspaceFilePreview = (filePath: string) => {
|
||||
const name = filePath.includes('/') ? filePath.slice(filePath.lastIndexOf('/') + 1) : filePath;
|
||||
previewLocalFile(taskId, 'workspace', filePath, name);
|
||||
};
|
||||
|
||||
// 1タブ分のコンテンツ。デスクトップ(クリック)とモバイル(スワイプ)の両方が
|
||||
// 同じ実体を使うため共通化(ロジック重複を避ける)。`chat` は ChatPane、それ以外は
|
||||
@@ -762,6 +768,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
fileManagement={fileManagement}
|
||||
subtaskActivities={subtaskActivities}
|
||||
onSubtaskFilePreview={handleSubtaskFilePreview}
|
||||
onWorkspaceFilePreview={handleWorkspaceFilePreview}
|
||||
shareToken={task?.shareToken ?? null}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import type { Space } from '../../api';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
@@ -16,8 +16,12 @@ import i18n from '../../i18n';
|
||||
const useSpacesMock = vi.fn();
|
||||
const useTaskListMock = vi.fn();
|
||||
const useAuthStateMock = vi.fn();
|
||||
const updateDisplayMock = { mutateAsync: vi.fn() };
|
||||
|
||||
vi.mock('../../hooks/useSpaces', () => ({ useSpaces: () => useSpacesMock() }));
|
||||
vi.mock('../../hooks/useSpaces', () => ({
|
||||
useSpaces: () => useSpacesMock(),
|
||||
useUpdateSpaceDisplayPrefs: () => updateDisplayMock,
|
||||
}));
|
||||
vi.mock('../../hooks/useTaskList', () => ({ useLocalTaskList: () => useTaskListMock() }));
|
||||
vi.mock('../../App', () => ({ useAuthState: () => useAuthStateMock() }));
|
||||
vi.mock('./SpaceFormDialog', () => ({
|
||||
@@ -52,6 +56,8 @@ beforeEach(() => {
|
||||
useSpacesMock.mockReset();
|
||||
useTaskListMock.mockReset();
|
||||
useAuthStateMock.mockReset();
|
||||
updateDisplayMock.mutateAsync.mockReset();
|
||||
updateDisplayMock.mutateAsync.mockResolvedValue({ favorite: false, hidden: false });
|
||||
useTaskListMock.mockReturnValue({ data: [] });
|
||||
useAuthStateMock.mockReturnValue({ mode: 'disabled' });
|
||||
});
|
||||
@@ -96,6 +102,43 @@ describe('SpaceRail', () => {
|
||||
expect(screen.getByText('Project A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows favorites in a dedicated section and hides hidden workspaces from the normal list', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [
|
||||
space({ id: 'fav', kind: 'case', title: 'Favorite Project', favorite: true }),
|
||||
space({ id: 'normal', kind: 'case', title: 'Normal Project' }),
|
||||
space({ id: 'hidden', kind: 'case', title: 'Hidden Project', hidden: true }),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
const groups = screen.getAllByTestId('space-group');
|
||||
expect(groups.map((g) => g.getAttribute('data-group'))).toContain('お気に入り');
|
||||
expect(screen.getByText('Favorite Project')).toBeInTheDocument();
|
||||
expect(screen.getByText('Normal Project')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hidden Project')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId('space-hidden-toggle'));
|
||||
expect(screen.getByText('Hidden Project')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('space-hidden-badge')).toHaveTextContent('非表示');
|
||||
});
|
||||
|
||||
it('search includes hidden workspaces with a hidden badge', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [
|
||||
space({ id: 'visible', kind: 'case', title: 'Visible Project' }),
|
||||
space({ id: 'hidden', kind: 'case', title: 'Hidden Project', hidden: true }),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
fireEvent.change(screen.getByTestId('space-search-input'), { target: { value: 'hidden' } });
|
||||
expect(screen.getByText('Hidden Project')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('space-hidden-badge')).toHaveTextContent('非表示');
|
||||
});
|
||||
|
||||
it('calls onSelect with the space id when a row is clicked', () => {
|
||||
const onSelect = vi.fn();
|
||||
useSpacesMock.mockReturnValue({
|
||||
@@ -165,4 +208,21 @@ describe('SpaceRail', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith('new-space');
|
||||
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('updates display settings from the row menu and can undo the action', async () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('space-row-menu'));
|
||||
fireEvent.click(screen.getByText('非表示にする'));
|
||||
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { hidden: true } }));
|
||||
expect(screen.getByTestId('space-display-toast')).toHaveTextContent('「Project A」を非表示にしました');
|
||||
|
||||
fireEvent.click(screen.getByTestId('space-display-undo'));
|
||||
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { favorite: false, hidden: false } }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthState } from '../../App';
|
||||
import { useSpaces } from '../../hooks/useSpaces';
|
||||
import { useSpaces, useUpdateSpaceDisplayPrefs } from '../../hooks/useSpaces';
|
||||
import { useLocalTaskList } from '../../hooks/useTaskList';
|
||||
import { sortSpacesForRail } from '../../lib/spaceSort';
|
||||
import { countRunningTasksForSpace } from '../../lib/spaceTasks';
|
||||
@@ -14,60 +14,113 @@ interface SpaceRailProps {
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
// 可視性ラベルは 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
|
||||
const GROUP_BAND_INDIVIDUAL = '#94a3b8';
|
||||
const GROUP_BAND_FAVORITE = '#eab308';
|
||||
|
||||
interface SpaceGroupDef {
|
||||
/** Stable key used for data-group (test selector); decoupled from the display label. */
|
||||
key: string;
|
||||
label: string;
|
||||
band: string;
|
||||
spaces: Space[];
|
||||
}
|
||||
|
||||
interface UndoState {
|
||||
spaceId: string;
|
||||
title: string;
|
||||
prev: { favorite: boolean; hidden: boolean };
|
||||
message: string;
|
||||
}
|
||||
|
||||
const normalize = (value: string) => value.trim().toLocaleLowerCase();
|
||||
const isFavorite = (space: Space) => space.favorite === true;
|
||||
const isHidden = (space: Space) => space.hidden === true;
|
||||
|
||||
export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const { data: spaces, isLoading, isError } = useSpaces();
|
||||
// 実行中件数の算出元。リスト API はスペースで絞らないので全件を保持しており、
|
||||
// FAST ポーリングで自動更新される。スペースごとにクライアント側で数える。
|
||||
const updateDisplay = useUpdateSpaceDisplayPrefs();
|
||||
const { data: tasks } = useLocalTaskList();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [undo, setUndo] = useState<UndoState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const auth = useAuthState();
|
||||
const myUserId = auth.mode === 'authenticated' ? auth.user.id : null;
|
||||
// 認証無効 (no-auth 単独利用) は admin 同等に扱う(App.tsx の isAdmin と同じ規約)。
|
||||
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
|
||||
// case スペースの id 集合。個人スペースバッジの「個人バケツ」判定に使う。
|
||||
const caseSpaceIds = useMemo(
|
||||
() => new Set((spaces ?? []).filter(s => s.kind === 'case').map(s => s.id)),
|
||||
[spaces],
|
||||
);
|
||||
|
||||
const sorted = useMemo(() => sortSpacesForRail(spaces ?? []), [spaces]);
|
||||
// 他ユーザー所有のスペースが一覧に混在するとき(=admin が全ユーザーのスペースを
|
||||
// 見ている場合)だけ「自分」バッジを出す。単一ユーザーの一覧では全部自分なので
|
||||
// ノイズにしかならず、出さない(issue #003)。
|
||||
const caseSpaceIds = useMemo(
|
||||
() => new Set(sorted.filter(s => s.kind === 'case').map(s => s.id)),
|
||||
[sorted],
|
||||
);
|
||||
const hasOthersSpaces = useMemo(
|
||||
() => myUserId != null && sorted.some(s => s.ownerId != null && s.ownerId !== myUserId),
|
||||
[sorted, myUserId],
|
||||
);
|
||||
|
||||
const q = normalize(query);
|
||||
const searching = q.length > 0;
|
||||
const matchesQuery = (space: Space) => normalize(space.title).includes(q);
|
||||
|
||||
const visibleSpaces = sorted.filter(s => !isHidden(s));
|
||||
const hiddenSpaces = sorted.filter(isHidden);
|
||||
const searchResults = searching ? sorted.filter(matchesQuery) : [];
|
||||
const favoriteSpaces = !searching ? visibleSpaces.filter(isFavorite) : [];
|
||||
const regularSpaces = !searching ? visibleSpaces.filter(s => !isFavorite(s)) : [];
|
||||
|
||||
const groups = useMemo<SpaceGroupDef[]>(() => {
|
||||
const personal = sorted.filter(s => s.kind === 'personal');
|
||||
const cases = sorted.filter(s => s.kind !== 'personal');
|
||||
if (searching) {
|
||||
return searchResults.length > 0
|
||||
? [{ key: '検索結果', label: t('rail.searchResults'), band: GROUP_BAND_INDIVIDUAL, spaces: searchResults }]
|
||||
: [];
|
||||
}
|
||||
|
||||
const out: SpaceGroupDef[] = [];
|
||||
if (favoriteSpaces.length > 0) {
|
||||
out.push({ key: 'お気に入り', label: t('rail.group.favorites'), band: GROUP_BAND_FAVORITE, spaces: favoriteSpaces });
|
||||
}
|
||||
|
||||
const personal = regularSpaces.filter(s => s.kind === 'personal');
|
||||
const cases = regularSpaces.filter(s => s.kind !== 'personal');
|
||||
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]);
|
||||
}, [favoriteSpaces, regularSpaces, searchResults, searching, t]);
|
||||
|
||||
const runningCount = (space: Space) =>
|
||||
countRunningTasksForSpace(tasks ?? [], space, { viewerId: myUserId, isAdmin, caseSpaceIds });
|
||||
|
||||
const changeDisplay = async (space: Space, patch: { favorite?: boolean; hidden?: boolean }, message: string) => {
|
||||
setOpenMenuId(null);
|
||||
setError(null);
|
||||
const prev = { favorite: isFavorite(space), hidden: isHidden(space) };
|
||||
try {
|
||||
await updateDisplay.mutateAsync({ id: space.id, patch });
|
||||
setUndo({ spaceId: space.id, title: space.title, prev, message });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('rail.displayUpdateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const undoChange = async () => {
|
||||
if (!undo) return;
|
||||
setError(null);
|
||||
try {
|
||||
await updateDisplay.mutateAsync({ id: undo.spaceId, patch: undo.prev });
|
||||
setUndo(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('rail.displayUpdateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" data-testid="space-rail">
|
||||
@@ -83,6 +136,39 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-hairline px-2 py-2">
|
||||
<label className="relative block">
|
||||
<span className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" aria-hidden>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="7" cy="7" r="4" />
|
||||
<path d="M10 10l3 3" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
data-testid="space-search-input"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('rail.searchPlaceholder')}
|
||||
className="h-8 w-full rounded-md border border-hairline bg-surface pl-7 pr-7 text-xs text-slate-800 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-300 focus:bg-white"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-search-clear"
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-1 text-slate-400 hover:bg-surface-2 hover:text-slate-700"
|
||||
aria-label={t('rail.clearSearch')}
|
||||
title={t('rail.clearSearch')}
|
||||
>
|
||||
<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="M4 4l8 8" />
|
||||
<path d="M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{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>}
|
||||
@@ -101,19 +187,78 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
key={s.id}
|
||||
space={s}
|
||||
active={s.id === selectedId}
|
||||
menuOpen={openMenuId === s.id}
|
||||
onToggleMenu={() => setOpenMenuId(openMenuId === s.id ? null : s.id)}
|
||||
onSelect={onSelect}
|
||||
runningCount={countRunningTasksForSpace(tasks ?? [], s, { viewerId: myUserId, isAdmin, caseSpaceIds })}
|
||||
runningCount={runningCount(s)}
|
||||
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
|
||||
onFavorite={() => changeDisplay(s, { favorite: !isFavorite(s) }, isFavorite(s) ? t('rail.toast.favoriteRemoved', { title: s.title }) : t('rail.toast.favoriteAdded', { title: s.title }))}
|
||||
onHide={() => changeDisplay(s, { hidden: true }, t('rail.toast.hidden', { title: s.title }))}
|
||||
onRestore={() => changeDisplay(s, { hidden: false }, t('rail.toast.restored', { title: s.title }))}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{!isLoading && !isError && sorted.length === 0 && (
|
||||
{!isLoading && !isError && !searching && sorted.length === 0 && (
|
||||
<p className="px-1 py-2 text-xs text-slate-500">{t('rail.empty')}</p>
|
||||
)}
|
||||
{!isLoading && !isError && searching && searchResults.length === 0 && (
|
||||
<p className="px-1 py-2 text-xs text-slate-500">{t('rail.noSearchResults')}</p>
|
||||
)}
|
||||
|
||||
{!searching && hiddenSpaces.length > 0 && (
|
||||
<section data-testid="space-hidden-section" className="mt-2 border-t border-hairline pt-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-hidden-toggle"
|
||||
onClick={() => setShowHidden(v => !v)}
|
||||
className="mb-1 flex w-full items-center justify-between rounded px-1 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 hover:bg-surface-2 hover:text-slate-600"
|
||||
>
|
||||
<span>{t('rail.hiddenToggle', { count: hiddenSpaces.length })}</span>
|
||||
<svg className={`h-3.5 w-3.5 transition-transform ${showHidden ? 'rotate-180' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
{showHidden && hiddenSpaces.map(s => (
|
||||
<SpaceRow
|
||||
key={s.id}
|
||||
space={s}
|
||||
active={s.id === selectedId}
|
||||
menuOpen={openMenuId === s.id}
|
||||
onToggleMenu={() => setOpenMenuId(openMenuId === s.id ? null : s.id)}
|
||||
onSelect={onSelect}
|
||||
runningCount={runningCount(s)}
|
||||
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
|
||||
onFavorite={() => changeDisplay(s, { favorite: !isFavorite(s) }, isFavorite(s) ? t('rail.toast.favoriteRemoved', { title: s.title }) : t('rail.toast.favoriteAdded', { title: s.title }))}
|
||||
onHide={() => changeDisplay(s, { hidden: true }, t('rail.toast.hidden', { title: s.title }))}
|
||||
onRestore={() => changeDisplay(s, { hidden: false }, t('rail.toast.restored', { title: s.title }))}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(undo || error) && (
|
||||
<div data-testid="space-display-toast" className="border-t border-hairline bg-slate-900 px-3 py-2 text-xs text-white">
|
||||
{error ? (
|
||||
<span>{error}</span>
|
||||
) : undo ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate">{undo.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-display-undo"
|
||||
onClick={undoChange}
|
||||
className="shrink-0 rounded border border-white/30 px-2 py-1 font-semibold hover:bg-white/10"
|
||||
>
|
||||
{t('rail.undo')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<SpaceFormDialog
|
||||
onClose={() => setShowCreate(false)}
|
||||
@@ -130,65 +275,124 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
function SpaceRow({
|
||||
space,
|
||||
active,
|
||||
menuOpen,
|
||||
onToggleMenu,
|
||||
onSelect,
|
||||
runningCount,
|
||||
mine,
|
||||
onFavorite,
|
||||
onHide,
|
||||
onRestore,
|
||||
}: {
|
||||
space: Space;
|
||||
active: boolean;
|
||||
menuOpen: boolean;
|
||||
onToggleMenu: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
runningCount: number;
|
||||
/** 他ユーザーのスペースが混在する一覧で、これが閲覧者自身の所有なら true。 */
|
||||
mine?: boolean;
|
||||
onFavorite: () => void;
|
||||
onHide: () => void;
|
||||
onRestore: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const dot = space.brandColor ?? 'var(--brand-primary)';
|
||||
const runningStyle = statusTone('running');
|
||||
const hidden = isHidden(space);
|
||||
const favorite = isFavorite(space);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
data-testid="space-row"
|
||||
data-space-kind={space.kind}
|
||||
data-space-id={space.id}
|
||||
data-space-hidden={hidden ? '1' : undefined}
|
||||
data-space-favorite={favorite ? '1' : undefined}
|
||||
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 ${
|
||||
className={`group relative mb-0.5 flex w-full items-center rounded-md border transition-colors ${
|
||||
active
|
||||
? 'border-hairline bg-[var(--brand-primary-soft)]'
|
||||
: 'border-transparent hover:bg-surface-2'
|
||||
: hidden
|
||||
? 'border-transparent opacity-75 hover:bg-surface-2'
|
||||
: 'border-transparent hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: dot }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1 truncate text-[12.5px] font-semibold text-slate-800">{space.title}</span>
|
||||
{mine && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(space.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
|
||||
>
|
||||
<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>
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: dot }}
|
||||
aria-hidden
|
||||
/>
|
||||
{favorite && !hidden && (
|
||||
<span className="shrink-0 text-[11px] text-amber-500" title={t('rail.favorite')} aria-label={t('rail.favorite')}>★</span>
|
||||
)}
|
||||
<span className="flex-1 truncate text-[12.5px] font-semibold text-slate-800">{space.title}</span>
|
||||
{hidden && (
|
||||
<span data-testid="space-hidden-badge" className="shrink-0 rounded-full bg-slate-100 px-1.5 py-0.5 text-[9px] font-bold tracking-wide text-slate-500">
|
||||
{t('rail.hiddenBadge')}
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-row-menu"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleMenu(); }}
|
||||
className="mr-1 shrink-0 rounded p-1 text-slate-400 opacity-100 hover:bg-white/70 hover:text-slate-700 md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100"
|
||||
aria-label={t('rail.menuLabel', { title: space.title })}
|
||||
title={t('rail.menu')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
|
||||
<circle cx="4" cy="8" r="1.2" />
|
||||
<circle cx="8" cy="8" r="1.2" />
|
||||
<circle cx="12" cy="8" r="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div data-testid="space-row-menu-panel" className="absolute right-1 top-8 z-20 w-40 rounded-md border border-hairline bg-white py-1 text-xs shadow-lg">
|
||||
{!hidden && (
|
||||
<button type="button" onClick={onFavorite} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
|
||||
{favorite ? t('rail.menuUnfavorite') : t('rail.menuFavorite')}
|
||||
</button>
|
||||
)}
|
||||
{hidden ? (
|
||||
<button type="button" onClick={onRestore} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
|
||||
{t('rail.menuRestore')}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={onHide} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
|
||||
{t('rail.menuHide')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user