mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
Merge branch 'dev' into feature/naver-support
This commit is contained in:
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-010
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -197,4 +197,127 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Failed to load tokens');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ sessions: [] });
|
||||
})
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({ sessions: [] })
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No active OAuth sessions');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
client_name: 'Claude Desktop',
|
||||
username: 'alice',
|
||||
scopes: ['trips:read', 'budget:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('trips:read')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
|
||||
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('App');
|
||||
// "+1 more" button should appear
|
||||
const moreBtn = await screen.findByText(/\+1 more/);
|
||||
expect(moreBtn).toBeInTheDocument();
|
||||
await user.click(moreBtn);
|
||||
// After expand, "show less" appears
|
||||
expect(await screen.findByText('show less')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/5', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Revoke Me');
|
||||
|
||||
// Click the revoke (trash) button next to the session
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
|
||||
// Confirmation modal opens
|
||||
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
|
||||
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/oauth-sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/admin/oauth-sessions/6', () =>
|
||||
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Error Session');
|
||||
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
await user.click(deleteBtn);
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await screen.findByText('Failed to revoke session');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2 } from 'lucide-react'
|
||||
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface AdminOAuthSession {
|
||||
id: number
|
||||
client_id: string
|
||||
client_name: string
|
||||
user_id: number
|
||||
username: string
|
||||
scopes: string[]
|
||||
access_token_expires_at: string
|
||||
refresh_token_expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface AdminMcpToken {
|
||||
id: number
|
||||
name: string
|
||||
@@ -14,21 +26,49 @@ interface AdminMcpToken {
|
||||
username: string
|
||||
}
|
||||
|
||||
const SCOPES_PREVIEW = 6
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [tokensLoading, setTokensLoading] = useState(true)
|
||||
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||
|
||||
const toggleScopes = (id: number) =>
|
||||
setExpandedScopes(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
adminApi.oauthSessions()
|
||||
.then(d => setSessions(d.sessions || []))
|
||||
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
|
||||
.finally(() => setSessionsLoading(false))
|
||||
|
||||
adminApi.mcpTokens()
|
||||
.then(d => setTokens(d.tokens || []))
|
||||
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||
.finally(() => setIsLoading(false))
|
||||
.finally(() => setTokensLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await adminApi.revokeOAuthSession(id)
|
||||
setSessions(prev => prev.filter(s => s.id !== id))
|
||||
setRevokeConfirmId(null)
|
||||
toast.success(t('admin.oauthSessions.revokeSuccess'))
|
||||
} catch {
|
||||
toast.error(t('admin.oauthSessions.revokeError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deleteMcpToken(id)
|
||||
@@ -47,55 +87,156 @@ export default function AdminMcpTokensPanel() {
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
{/* OAuth Sessions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||
<span>{t('admin.oauthSessions.owner')}</span>
|
||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{sessions.map((session, i) => {
|
||||
const expanded = expandedScopes.has(session.id)
|
||||
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div key={session.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
|
||||
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{session.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP Tokens */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{tokensLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(token.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('GitHubPanel', () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
render(<GitHubPanel isPrerelease={true} />);
|
||||
await screen.findByText('v3.0.0-beta.1');
|
||||
expect(screen.getByText('Pre-release')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -6,12 +6,18 @@ import apiClient from '../../api/client'
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
export default function GitHubPanel() {
|
||||
interface GithubRelease {
|
||||
id: number
|
||||
prerelease: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState([])
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@@ -273,7 +279,7 @@ export default function GitHubPanel() {
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{releases.map((release, idx) => {
|
||||
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-020
|
||||
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BudgetPanel from './BudgetPanel';
|
||||
@@ -418,4 +419,80 @@ describe('BudgetPanel', () => {
|
||||
// Grand total card shows 300.00
|
||||
expect(screen.getByText('300.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
// Use a user with id != 1 so they're not the owner
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Read Only Item');
|
||||
// In read-only mode the Delete button should not be visible
|
||||
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Train');
|
||||
// expense_date is rendered as plain text in read-only mode
|
||||
await screen.findByText('2025-06-15');
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
|
||||
const user = userEvent.setup();
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
|
||||
],
|
||||
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
|
||||
})
|
||||
),
|
||||
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
|
||||
);
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Lunch');
|
||||
// Trigger settlement display
|
||||
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
|
||||
await user.click(settlementBtn);
|
||||
await screen.findByText('alice');
|
||||
// Avatar image should be rendered for alice
|
||||
const avatarImg = screen.getAllByRole('img');
|
||||
expect(avatarImg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Snack');
|
||||
// When expense_date is null, the fallback '—' is shown
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -956,15 +956,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{pieSegments.map(seg => {
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||
return (
|
||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
|
||||
<div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -370,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { deleteTimersRef.current.forEach(clearTimeout) }
|
||||
}, [])
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const messagesRef = useRef(messages)
|
||||
@@ -483,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
setTimeout(async () => {
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||
} catch {}
|
||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||
}, 400)
|
||||
deleteTimersRef.current.push(t)
|
||||
}, [tripId])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
@@ -762,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}>
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
|
||||
@@ -16,12 +16,13 @@ function formatTime(timeStr, is12h) {
|
||||
}
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const d = new Date(date + 'T00:00:00')
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
||||
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
|
||||
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
|
||||
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import JournalBody from './JournalBody';
|
||||
|
||||
describe('JournalBody', () => {
|
||||
it('FE-COMP-JOURNALBODY-001: renders plain text content', () => {
|
||||
render(<JournalBody text="Hello traveller" />);
|
||||
expect(screen.getByText('Hello traveller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-002: renders bold markdown as <strong>', () => {
|
||||
const { container } = render(<JournalBody text="This is **bold** text" />);
|
||||
const strong = container.querySelector('strong');
|
||||
expect(strong).toBeInTheDocument();
|
||||
expect(strong!.textContent).toBe('bold');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => {
|
||||
render(<JournalBody text="[Visit](https://example.com)" />);
|
||||
const link = screen.getByRole('link', { name: 'Visit' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
|
||||
const { container } = render(<JournalBody text="## Section Title" />);
|
||||
const h2 = container.querySelector('h2');
|
||||
expect(h2).toBeInTheDocument();
|
||||
expect(h2!.textContent).toBe('Section Title');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => {
|
||||
const { container } = render(<JournalBody text="" />);
|
||||
expect(container.querySelector('.journal-body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
export default function JournalBody({ text, dark }: Props) {
|
||||
return (
|
||||
<div className="journal-body" style={{
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 1.6,
|
||||
color: 'inherit',
|
||||
}}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 style={{ fontFamily: 'inherit', fontSize: '1.3em', fontWeight: 700, margin: '16px 0 6px', lineHeight: 1.3 }}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 style={{ fontFamily: 'inherit', fontSize: '1.15em', fontWeight: 600, margin: '14px 0 4px', lineHeight: 1.3 }}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 style={{ fontFamily: 'inherit', fontSize: '1.05em', fontWeight: 600, margin: '12px 0 4px', lineHeight: 1.4 }}>{children}</h3>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderLeft: `3px solid var(--journal-accent)`,
|
||||
paddingLeft: 16, margin: '12px 0',
|
||||
fontStyle: 'italic', color: 'var(--journal-muted)',
|
||||
}}>{children}</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ul>,
|
||||
ol: ({ children }) => <ol style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ol>,
|
||||
li: ({ children }) => <li style={{ margin: '4px 0' }}>{children}</li>,
|
||||
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.includes('language-')
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre style={{
|
||||
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
|
||||
borderRadius: 8, padding: 14, overflowX: 'auto',
|
||||
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
|
||||
}}>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code style={{
|
||||
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
|
||||
borderRadius: 4, padding: '2px 5px', fontSize: '0.9em', fontFamily: 'monospace',
|
||||
}}>{children}</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-006
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
// Leaflet does not work in jsdom — mock the entire library
|
||||
vi.mock('leaflet', () => {
|
||||
const mockMarker = {
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
bindTooltip: vi.fn().mockReturnThis(),
|
||||
on: vi.fn().mockReturnThis(),
|
||||
setIcon: vi.fn(),
|
||||
setZIndexOffset: vi.fn(),
|
||||
getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
};
|
||||
const mockMap = {
|
||||
remove: vi.fn(),
|
||||
invalidateSize: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
};
|
||||
return {
|
||||
default: {
|
||||
map: vi.fn(() => mockMap),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
marker: vi.fn(() => mockMarker),
|
||||
polyline: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
},
|
||||
map: vi.fn(() => mockMap),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
marker: vi.fn(() => mockMarker),
|
||||
polyline: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { buildSettings } from '../../../tests/helpers/factories';
|
||||
import L from 'leaflet';
|
||||
import JourneyMap from './JourneyMap';
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
|
||||
const entriesWithCoords = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
|
||||
];
|
||||
|
||||
const entriesWithoutCoords = [
|
||||
{ id: 'e3', lat: 0, lng: 0, title: 'Unknown Place', mood: null, entry_date: '2025-06-03' },
|
||||
];
|
||||
|
||||
const mixedEntries = [
|
||||
...entriesWithCoords,
|
||||
...entriesWithoutCoords,
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('JourneyMap', () => {
|
||||
it('FE-COMP-JOURNEYMAP-001: renders map container', () => {
|
||||
const { container } = render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// The component renders a div with a child div ref for the Leaflet map
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
expect(L.map).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-002: renders markers for entries with coordinates', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// Two entries with valid lat/lng should produce two markers
|
||||
expect(L.marker).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-003: does not render markers for entries without coordinates', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithoutCoords} />
|
||||
);
|
||||
// Entry with lat=0 and lng=0 is filtered out by buildMarkerItems (if (e.lat && e.lng))
|
||||
expect(L.marker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-004: renders polyline connecting entries', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// With 2+ marker items, a route polyline is drawn
|
||||
expect(L.polyline).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-005: shows entry title in marker tooltip', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// Each marker calls bindTooltip with the entry label
|
||||
const mockMarkerInstance = (L.marker as any).mock.results[0].value;
|
||||
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith(
|
||||
'Paris',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(
|
||||
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
expect(ref.current).not.toBeNull();
|
||||
expect(typeof ref.current!.focusMarker).toBe('function');
|
||||
expect(typeof ref.current!.highlightMarker).toBe('function');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-007: renders SVG pin markers via divIcon', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// Each marker is created with L.divIcon containing SVG html
|
||||
expect(L.divIcon).toHaveBeenCalledTimes(2);
|
||||
const firstCall = (L.divIcon as any).mock.calls[0][0];
|
||||
expect(firstCall.html).toContain('<svg');
|
||||
expect(firstCall.html).toContain('</svg>');
|
||||
// Marker index label "1" for first entry
|
||||
expect(firstCall.html).toContain('>1<');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => {
|
||||
const entriesWithMood = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' },
|
||||
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Sad Berlin', mood: 'sad', entry_date: '2025-06-02' },
|
||||
];
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithMood} />
|
||||
);
|
||||
// Markers are still created (mood does not prevent rendering)
|
||||
expect(L.marker).toHaveBeenCalledTimes(2);
|
||||
// Tooltips use the entry titles
|
||||
const mockMarker1 = (L.marker as any).mock.results[0].value;
|
||||
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith(
|
||||
'Happy Paris',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
const mockMarker2 = (L.marker as any).mock.results[1].value;
|
||||
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith(
|
||||
'Sad Berlin',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-009: draws route polyline connecting multiple markers', () => {
|
||||
const threeEntries = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
|
||||
{ id: 'e3', lat: 41.9028, lng: 12.4964, title: 'Rome', mood: null, entry_date: '2025-06-03' },
|
||||
];
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={threeEntries} />
|
||||
);
|
||||
// Route polyline is drawn for items.length > 1
|
||||
expect(L.polyline).toHaveBeenCalled();
|
||||
const polylineCall = (L.polyline as any).mock.calls[0];
|
||||
// Should contain coordinates for all three entries
|
||||
expect(polylineCall[0].length).toBe(3);
|
||||
// Verify dashed style
|
||||
expect(polylineCall[1]).toMatchObject({ dashArray: '4 6' });
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-010: fitBounds is called for auto-zoom', () => {
|
||||
// Trigger requestAnimationFrame synchronously
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
|
||||
const mockMap = (L.map as any).mock.results[0].value;
|
||||
// fitBounds is called inside requestAnimationFrame with the collected coordinates
|
||||
expect(mockMap.fitBounds).toHaveBeenCalled();
|
||||
expect(L.latLngBounds).toHaveBeenCalled();
|
||||
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-011: single entry creates marker but no polyline', () => {
|
||||
const singleEntry = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Solo Paris', mood: null, entry_date: '2025-06-01' },
|
||||
];
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={singleEntry} />
|
||||
);
|
||||
// One marker created
|
||||
expect(L.marker).toHaveBeenCalledTimes(1);
|
||||
// No route polyline — polyline is only drawn when items.length > 1
|
||||
expect(L.polyline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-012: renders zoom control buttons', () => {
|
||||
const { container } = render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// The component renders zoom in (+) and zoom out (−) buttons
|
||||
const buttons = container.querySelectorAll('button');
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons[0].textContent).toBe('+');
|
||||
expect(buttons[1].textContent).toBe('−');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
label: string
|
||||
mood?: string | null
|
||||
time: string
|
||||
}
|
||||
|
||||
export interface JourneyMapHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
checkins: any[]
|
||||
entries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
height?: number
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
}
|
||||
|
||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
const items: MapMarkerItem[] = []
|
||||
for (const e of entries) {
|
||||
if (e.lat && e.lng) {
|
||||
items.push({
|
||||
id: e.id,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
label: e.title || 'Entry',
|
||||
mood: e.mood,
|
||||
time: e.entry_date,
|
||||
})
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => a.time.localeCompare(b.time))
|
||||
return items
|
||||
}
|
||||
|
||||
const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#FAFAFA')
|
||||
: (highlighted ? '#18181B' : '#18181B')
|
||||
const textColor = dark
|
||||
? (highlighted ? '#18181B' : '#18181B')
|
||||
: (highlighted ? '#fff' : '#fff')
|
||||
const stroke = dark ? '#3F3F46' : '#fff'
|
||||
const shadow = highlighted
|
||||
? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))'
|
||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const label = String(index + 1)
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
|
||||
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
||||
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||
</svg>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
||||
const itemsRef = useRef<MapMarkerItem[]>([])
|
||||
const highlightedRef = useRef<string | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
|
||||
const highlightMarker = useCallback((id: string | null) => {
|
||||
const prev = highlightedRef.current
|
||||
highlightedRef.current = id
|
||||
const isDark = !!darkRef.current
|
||||
|
||||
if (prev && prev !== id) {
|
||||
const marker = markersRef.current.get(prev)
|
||||
const item = itemsRef.current.find(i => i.id === prev)
|
||||
if (marker && item) {
|
||||
const idx = itemsRef.current.indexOf(item)
|
||||
marker.setIcon(L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(idx, false, isDark),
|
||||
}))
|
||||
marker.setZIndexOffset(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const marker = markersRef.current.get(id)
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (marker && item) {
|
||||
const idx = itemsRef.current.indexOf(item)
|
||||
marker.setIcon(L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(idx, true, isDark),
|
||||
}))
|
||||
marker.setZIndexOffset(1000)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (marker && mapRef.current) {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove()
|
||||
mapRef.current = null
|
||||
}
|
||||
markersRef.current.clear()
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
scrollWheelZoom: false,
|
||||
dragging: true,
|
||||
touchZoom: true,
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
const defaultTile = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||||
L.tileLayer(mapTileUrl || defaultTile, { maxZoom: 18 }).addTo(map)
|
||||
|
||||
const items = buildMarkerItems(entries)
|
||||
itemsRef.current = items
|
||||
|
||||
const allCoords: L.LatLngTuple[] = []
|
||||
|
||||
if (stableTrail.length > 1) {
|
||||
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
||||
L.polyline(coords, {
|
||||
color: '#6366f1', weight: 3, opacity: 0.4,
|
||||
dashArray: '6 4', lineCap: 'round',
|
||||
}).addTo(map)
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
}
|
||||
|
||||
// route polyline — subtle dashed connection
|
||||
if (items.length > 1) {
|
||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||
L.polyline(routeCoords, {
|
||||
color: dark ? '#71717A' : '#A1A1AA',
|
||||
weight: 1.5,
|
||||
opacity: 0.5,
|
||||
dashArray: '4 6',
|
||||
lineCap: 'round', lineJoin: 'round',
|
||||
}).addTo(map)
|
||||
}
|
||||
|
||||
// place markers
|
||||
items.forEach((item, i) => {
|
||||
const pos: L.LatLngTuple = [item.lat, item.lng]
|
||||
allCoords.push(pos)
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(i, false, !!dark),
|
||||
})
|
||||
|
||||
const marker = L.marker(pos, { icon }).addTo(map)
|
||||
marker.bindTooltip(item.label, {
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip',
|
||||
})
|
||||
|
||||
marker.on('click', () => {
|
||||
onMarkerClickRef.current?.(item.id)
|
||||
})
|
||||
|
||||
markersRef.current.set(item.id, marker)
|
||||
})
|
||||
|
||||
// fit bounds
|
||||
requestAnimationFrame(() => {
|
||||
if (!mapRef.current) return
|
||||
try {
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (mapRef.current) map.invalidateSize()
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, dark, mapTileUrl])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
if (!activeMarkerId || !mapRef.current) return
|
||||
// small delay to ensure markers are rendered after map build
|
||||
const timer = setTimeout(() => {
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (marker && mapRef.current) {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
}
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeMarkerId])
|
||||
|
||||
const zoomIn = () => mapRef.current?.zoomIn()
|
||||
const zoomOut = () => mapRef.current?.zoomOut()
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||
color: dark ? '#fff' : '#18181B',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||
}}
|
||||
>+</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||
color: dark ? '#fff' : '#18181B',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||
}}
|
||||
>−</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default JourneyMap
|
||||
@@ -0,0 +1,72 @@
|
||||
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import MarkdownToolbar from './MarkdownToolbar';
|
||||
import React from 'react';
|
||||
|
||||
function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.selectionStart = selectionStart;
|
||||
textarea.selectionEnd = selectionEnd;
|
||||
textarea.focus = vi.fn();
|
||||
textarea.setSelectionRange = vi.fn();
|
||||
return { current: textarea } as React.RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
describe('MarkdownToolbar', () => {
|
||||
let onUpdate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onUpdate = vi.fn();
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||
const ref = createTextareaRef();
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-002: buttons have correct title labels', () => {
|
||||
const ref = createTextareaRef();
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
expect(screen.getByTitle('Bold')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Italic')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Link')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Heading')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Quote')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('List')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Ordered')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-003: bold button wraps selected text with **', () => {
|
||||
const ref = createTextareaRef('hello world', 6, 11);
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Bold'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('hello **world**');
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-004: italic button wraps selected text with _', () => {
|
||||
const ref = createTextareaRef('hello world', 6, 11);
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Italic'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('hello _world_');
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-005: link button wraps selected text as markdown link', () => {
|
||||
const ref = createTextareaRef('click me', 0, 8);
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Link'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('[click me](url)');
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-006: heading button inserts line prefix', () => {
|
||||
const ref = createTextareaRef('my title', 0, 0);
|
||||
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
|
||||
fireEvent.click(screen.getByTitle('Heading'));
|
||||
expect(onUpdate).toHaveBeenCalledWith('## my title');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
onUpdate: (value: string) => void
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string }
|
||||
|
||||
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
|
||||
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
|
||||
{ icon: Italic, label: 'Italic', action: { type: 'wrap', before: '_', after: '_' } },
|
||||
{ icon: Heading2, label: 'Heading', action: { type: 'line', prefix: '## ' } },
|
||||
{ icon: Quote, label: 'Quote', action: { type: 'line', prefix: '> ' } },
|
||||
{ icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } },
|
||||
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
|
||||
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } },
|
||||
{ icon: Minus, label: 'Divider', action: { type: 'line', prefix: '\n---\n' } },
|
||||
]
|
||||
|
||||
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
|
||||
const apply = (action: FormatAction) => {
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
|
||||
const start = ta.selectionStart
|
||||
const end = ta.selectionEnd
|
||||
const text = ta.value
|
||||
const selected = text.slice(start, end)
|
||||
|
||||
let result: string
|
||||
let cursorPos: number
|
||||
|
||||
if (action.type === 'wrap') {
|
||||
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
|
||||
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length
|
||||
} else {
|
||||
// line prefix — find start of current line
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1
|
||||
result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart)
|
||||
cursorPos = start + action.prefix.length
|
||||
}
|
||||
|
||||
onUpdate(result)
|
||||
|
||||
// restore cursor after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(cursorPos, cursorPos)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 2, padding: '6px 4px',
|
||||
borderBottom: `1px solid var(--journal-border)`,
|
||||
overflowX: 'auto',
|
||||
}}>
|
||||
{ACTIONS.map(a => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
title={a.label}
|
||||
onClick={() => apply(a.action)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 6,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none',
|
||||
color: 'var(--journal-muted)', cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
<a.icon size={15} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-008
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import PhotoLightbox from './PhotoLightbox';
|
||||
|
||||
const samplePhotos = [
|
||||
{ id: 'p1', src: '/photos/1.jpg', caption: 'Sunset at the beach' },
|
||||
{ id: 'p2', src: '/photos/2.jpg', caption: 'Mountain trail' },
|
||||
{ id: 'p3', src: '/photos/3.jpg', caption: null },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('PhotoLightbox', () => {
|
||||
it('FE-COMP-LIGHTBOX-001: renders without crashing when open', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-002: shows photo image', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', '/photos/1.jpg');
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-003: shows close button', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// Close button exists (the X button in the top bar)
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-004: previous/next navigation works', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
// Initially shows photo 1
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', '/photos/1.jpg');
|
||||
|
||||
// Navigate to next photo via ArrowRight key
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/2.jpg');
|
||||
|
||||
// Navigate back via ArrowLeft key
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' });
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/1.jpg');
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-005: keyboard Escape closes lightbox', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-007: does not render when photos array is empty', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(<PhotoLightbox photos={[]} onClose={onClose} />);
|
||||
// Component returns null when photo is undefined (empty array, index 0 is undefined)
|
||||
expect(container.querySelector('img')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-008: calls onClose when close button clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
|
||||
// The close button is in the top bar — find the button and click it
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// The first button in the top bar is the close (X) button
|
||||
buttons[0].click();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
|
||||
interface LightboxPhoto {
|
||||
id: string
|
||||
src: string
|
||||
caption?: string | null
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
photos: LightboxPhoto[]
|
||||
startIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
|
||||
const [idx, setIdx] = useState(startIndex)
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
const photo = photos[idx]
|
||||
const hasPrev = idx > 0
|
||||
const hasNext = idx < photos.length - 1
|
||||
|
||||
const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev])
|
||||
const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [prev, next, onClose])
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
const t = e.touches[0]
|
||||
touchStart.current = { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
if (!touchStart.current) return
|
||||
const t = e.changedTouches[0]
|
||||
const dx = t.clientX - touchStart.current.x
|
||||
const dy = t.clientY - touchStart.current.y
|
||||
|
||||
// swipe down to close
|
||||
if (dy > 80 && Math.abs(dx) < 60) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
// horizontal swipe
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||
if (dx < 0) next()
|
||||
else prev()
|
||||
}
|
||||
touchStart.current = null
|
||||
}
|
||||
|
||||
if (!photo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 500,
|
||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Photo area — centered with nav overlays */}
|
||||
<div
|
||||
className="group/lightbox"
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
|
||||
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
|
||||
{idx + 1} / {photos.length}
|
||||
</span>
|
||||
<button onClick={onClose} style={{
|
||||
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
|
||||
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
|
||||
{hasPrev && (
|
||||
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
|
||||
position: 'absolute', left: 16, zIndex: 5,
|
||||
width: 44, height: 44, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Photo */}
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
style={{
|
||||
maxWidth: '92vw', maxHeight: '92vh',
|
||||
objectFit: 'contain', borderRadius: 4,
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Next button */}
|
||||
{hasNext && (
|
||||
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
|
||||
position: 'absolute', right: 16, zIndex: 5,
|
||||
width: 44, height: 44, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Caption — bottom center overlay */}
|
||||
{photo.caption && (
|
||||
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
|
||||
<p style={{
|
||||
fontSize: 14, fontStyle: 'italic',
|
||||
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
|
||||
padding: '6px 14px', borderRadius: 10,
|
||||
}}>{photo.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// FE-COMP-MOOD-001 to FE-COMP-MOOD-005
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MOODS, WEATHERS, getMood, moodColor, tagColors, TAG_STYLES, MOOD_DEFAULT_COLOR } from './moodConfig';
|
||||
|
||||
describe('moodConfig', () => {
|
||||
it('FE-COMP-MOOD-001: MOODS contains all five mood definitions', () => {
|
||||
const ids = MOODS.map(m => m.id);
|
||||
expect(ids).toEqual(['amazing', 'good', 'neutral', 'tired', 'rough']);
|
||||
expect(MOODS).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-002: every mood has valid hex color and css var', () => {
|
||||
for (const mood of MOODS) {
|
||||
expect(mood.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
expect(mood.cssVar).toMatch(/^var\(--mood-.+\)$/);
|
||||
expect(mood.icon).toBeDefined();
|
||||
expect(mood.label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-003: getMood returns correct mood or undefined', () => {
|
||||
expect(getMood('amazing')?.id).toBe('amazing');
|
||||
expect(getMood('rough')?.color).toBe('#9B8EC4');
|
||||
expect(getMood('nonexistent')).toBeUndefined();
|
||||
expect(getMood(null)).toBeUndefined();
|
||||
expect(getMood(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-004: moodColor returns css var or fallback', () => {
|
||||
expect(moodColor('good')).toBe('var(--mood-good)');
|
||||
expect(moodColor(null)).toBe('var(--journal-faint)');
|
||||
expect(moodColor('unknown')).toBe('var(--journal-faint)');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-005: WEATHERS contains all eight entries with icons', () => {
|
||||
expect(WEATHERS).toHaveLength(8);
|
||||
const ids = WEATHERS.map(w => w.id);
|
||||
expect(ids).toContain('sunny');
|
||||
expect(ids).toContain('snowy');
|
||||
expect(ids).toContain('stormy');
|
||||
for (const w of WEATHERS) {
|
||||
expect(w.icon).toBeDefined();
|
||||
expect(w.label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagColors', () => {
|
||||
it('FE-COMP-MOOD-006: returns known tag colors for light and dark mode', () => {
|
||||
const light = tagColors('hidden gem', false);
|
||||
expect(light.bg).toBe('#dcfce7');
|
||||
expect(light.fg).toBe('#166534');
|
||||
|
||||
const dark = tagColors('hidden gem', true);
|
||||
expect(dark.bg).toBe('rgba(22,101,52,0.2)');
|
||||
expect(dark.fg).toBe('#86efac');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOOD-007: returns fallback colors for unknown tags', () => {
|
||||
const light = tagColors('random tag', false);
|
||||
expect(light.bg).toBe('rgba(0,0,0,0.05)');
|
||||
expect(light.fg).toBe('#374151');
|
||||
|
||||
const dark = tagColors('random tag', true);
|
||||
expect(dark.bg).toBe('rgba(255,255,255,0.07)');
|
||||
expect(dark.fg).toBe('#a1a1aa');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Sparkles, Sun, Minus, Moon, CloudRain, CloudSun, Cloud, CloudLightning, Snowflake, Thermometer, ThermometerSnowflake } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface MoodDef {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
color: string
|
||||
cssVar: string
|
||||
}
|
||||
|
||||
export const MOODS: MoodDef[] = [
|
||||
{ id: 'amazing', label: 'Amazing', icon: Sparkles, color: '#E8654A', cssVar: 'var(--mood-amazing)' },
|
||||
{ id: 'good', label: 'Good', icon: Sun, color: '#EF9F27', cssVar: 'var(--mood-good)' },
|
||||
{ id: 'neutral', label: 'Neutral', icon: Minus, color: '#94928C', cssVar: 'var(--mood-neutral)' },
|
||||
{ id: 'tired', label: 'Tired', icon: Moon, color: '#6B9BD2', cssVar: 'var(--mood-tired)' },
|
||||
{ id: 'rough', label: 'Rough', icon: CloudRain,color: '#9B8EC4', cssVar: 'var(--mood-rough)' },
|
||||
]
|
||||
|
||||
export const MOOD_DEFAULT_COLOR = '#D4D4D4'
|
||||
|
||||
export function getMood(id: string | null | undefined): MoodDef | undefined {
|
||||
if (!id) return undefined
|
||||
return MOODS.find(m => m.id === id)
|
||||
}
|
||||
|
||||
export function moodColor(id: string | null | undefined): string {
|
||||
return getMood(id)?.cssVar || 'var(--journal-faint)'
|
||||
}
|
||||
|
||||
export interface WeatherDef {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export const WEATHERS: WeatherDef[] = [
|
||||
{ id: 'sunny', label: 'Sunny', icon: Sun },
|
||||
{ id: 'partly', label: 'Partly cloudy', icon: CloudSun },
|
||||
{ id: 'cloudy', label: 'Cloudy', icon: Cloud },
|
||||
{ id: 'rainy', label: 'Rainy', icon: CloudRain },
|
||||
{ id: 'stormy', label: 'Stormy', icon: CloudLightning },
|
||||
{ id: 'snowy', label: 'Snowy', icon: Snowflake },
|
||||
{ id: 'hot', label: 'Hot', icon: Thermometer },
|
||||
{ id: 'cold', label: 'Cold', icon: ThermometerSnowflake },
|
||||
]
|
||||
|
||||
export function getWeather(id: string | null | undefined): WeatherDef | undefined {
|
||||
if (!id) return undefined
|
||||
return WEATHERS.find(w => w.id === id)
|
||||
}
|
||||
|
||||
export const TAG_STYLES: Record<string, { bg: string; fg: string; darkBg: string; darkFg: string }> = {
|
||||
'hidden gem': { bg: '#dcfce7', fg: '#166534', darkBg: 'rgba(22,101,52,0.2)', darkFg: '#86efac' },
|
||||
'must revisit': { bg: '#dbeafe', fg: '#1e40af', darkBg: 'rgba(30,64,175,0.2)', darkFg: '#93c5fd' },
|
||||
'best meal': { bg: '#fef3c7', fg: '#92400e', darkBg: 'rgba(146,64,14,0.2)', darkFg: '#fcd34d' },
|
||||
'tourist trap': { bg: '#fee2e2', fg: '#991b1b', darkBg: 'rgba(153,27,27,0.2)', darkFg: '#fca5a5' },
|
||||
'disaster': { bg: '#fce4ec', fg: '#880e4f', darkBg: 'rgba(136,14,79,0.2)', darkFg: '#f48fb1' },
|
||||
}
|
||||
|
||||
export function tagColors(tag: string, dark: boolean) {
|
||||
const known = TAG_STYLES[tag.toLowerCase()]
|
||||
if (known) return { bg: dark ? known.darkBg : known.bg, fg: dark ? known.darkFg : known.fg }
|
||||
return { bg: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)', fg: dark ? '#a1a1aa' : '#374151' }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// FE-UTIL-STRIPMD-001 to FE-UTIL-STRIPMD-006
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stripMarkdown } from './stripMarkdown';
|
||||
|
||||
describe('stripMarkdown', () => {
|
||||
it('FE-UTIL-STRIPMD-001: strips bold and italic formatting', () => {
|
||||
expect(stripMarkdown('**bold** and _italic_')).toBe('bold and italic');
|
||||
expect(stripMarkdown('__also bold__ and *also italic*')).toBe('also bold and also italic');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-002: strips headings', () => {
|
||||
expect(stripMarkdown('# Heading 1')).toBe('Heading 1');
|
||||
expect(stripMarkdown('## Heading 2')).toBe('Heading 2');
|
||||
expect(stripMarkdown('### Heading 3')).toBe('Heading 3');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-003: converts links to text and removes images', () => {
|
||||
expect(stripMarkdown('[click here](https://example.com)')).toBe('click here');
|
||||
expect(stripMarkdown('')).toBe('');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-004: strips code blocks and inline code', () => {
|
||||
expect(stripMarkdown('use `console.log`')).toBe('use console.log');
|
||||
expect(stripMarkdown('```\ncode block\n```')).toBe('');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-005: strips blockquotes and lists', () => {
|
||||
expect(stripMarkdown('> quoted text')).toBe('quoted text');
|
||||
expect(stripMarkdown('- item one')).toBe('item one');
|
||||
expect(stripMarkdown('1. first item')).toBe('first item');
|
||||
});
|
||||
|
||||
it('FE-UTIL-STRIPMD-006: strips strikethrough and horizontal rules', () => {
|
||||
expect(stripMarkdown('~~deleted~~')).toBe('deleted');
|
||||
expect(stripMarkdown('---')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Strip markdown formatting to get plain text for previews.
|
||||
* Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr.
|
||||
*/
|
||||
export function stripMarkdown(md: string): string {
|
||||
return md
|
||||
.replace(/^#{1,6}\s+/gm, '') // headings
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // images
|
||||
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
|
||||
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
|
||||
.replace(/`([^`]+)`/g, '$1') // inline code
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1') // bold **
|
||||
.replace(/__(.+?)__/g, '$1') // bold __
|
||||
.replace(/\*(.+?)\*/g, '$1') // italic *
|
||||
.replace(/_(.+?)_/g, '$1') // italic _
|
||||
.replace(/~~(.+?)~~/g, '$1') // strikethrough
|
||||
.replace(/^>\s?/gm, '') // blockquotes
|
||||
.replace(/^[-*+]\s+/gm, '') // unordered lists
|
||||
.replace(/^\d+\.\s+/gm, '') // ordered lists
|
||||
.replace(/^---+$/gm, '') // horizontal rules
|
||||
.replace(/\n{2,}/g, ' ') // collapse multiple newlines
|
||||
.replace(/\n/g, ' ') // remaining newlines → spaces
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import BottomNav from './BottomNav';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
mockNavigate.mockClear();
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
});
|
||||
|
||||
describe('BottomNav', () => {
|
||||
it('FE-COMP-BOTTOMNAV-001: renders without crashing', () => {
|
||||
render(<BottomNav />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
// Profile sheet shows username
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => {
|
||||
const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' });
|
||||
seedStore(useAuthStore, { user: adminUser, isAuthenticated: true });
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
// Sheet is open — username visible
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
// The outermost fixed div is the backdrop wrapper, clicking it triggers onClose
|
||||
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.click(backdrop);
|
||||
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
|
||||
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
|
||||
{ to: '/trips', label: 'Trips', icon: Plane },
|
||||
]
|
||||
|
||||
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
|
||||
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
|
||||
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
|
||||
journey: { to: '/journey', label: 'Journey', icon: Compass },
|
||||
}
|
||||
|
||||
export default function BottomNav() {
|
||||
const { t } = useTranslation()
|
||||
const darkMode = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const addons = useAddonStore(s => s.addons)
|
||||
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||
const [showProfile, setShowProfile] = useState(false)
|
||||
|
||||
const items = [...BASE_ITEMS]
|
||||
for (const addon of globalAddons) {
|
||||
const nav = ADDON_NAV[addon.id]
|
||||
if (nav) items.push(nav)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
|
||||
style={{
|
||||
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
background: dark ? 'rgba(9,9,11,0.96)' : 'rgba(255,255,255,0.96)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
|
||||
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowProfile(true)}
|
||||
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
|
||||
>
|
||||
<User size={22} strokeWidth={2} />
|
||||
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileSheet({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleNav = (path: string) => {
|
||||
onClose()
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose()
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
|
||||
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Handle */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
<div className="px-6 pb-4 pt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
|
||||
{(user?.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
|
||||
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
{user?.role === 'admin' && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
|
||||
<Shield size={10} /> Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
||||
|
||||
{/* Links */}
|
||||
<div className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => handleNav('/settings')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<Settings size={18} className="text-zinc-500" />
|
||||
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
|
||||
</button>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => handleNav('/admin')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<Shield size={18} className="text-zinc-500" />
|
||||
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
||||
|
||||
{/* Logout */}
|
||||
<div className="py-2 px-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
|
||||
>
|
||||
<LogOut size={18} className="text-red-500" />
|
||||
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import MobileTopHeader from './MobileTopHeader';
|
||||
|
||||
describe('MobileTopHeader', () => {
|
||||
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
|
||||
render(<MobileTopHeader title="Journeys" />);
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading.textContent).toBe('Journeys');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
|
||||
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
|
||||
expect(screen.getByText('3 trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
|
||||
const { container } = render(<MobileTopHeader title="Journeys" />);
|
||||
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
|
||||
expect(subtitleEl).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
|
||||
render(
|
||||
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
|
||||
return (
|
||||
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
|
||||
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ beforeEach(() => {
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true });
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
});
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
|
||||
|
||||
interface NavbarProps {
|
||||
tripTitle?: string
|
||||
@@ -27,14 +27,13 @@ interface Addon {
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
@@ -45,12 +44,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
if (user) loadAddons()
|
||||
}, [user, location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
import('../../api/client').then(({ authApi }) => {
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
@@ -75,7 +68,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
touchAction: 'manipulation',
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
height: 'var(--nav-h)',
|
||||
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{showBack && (
|
||||
@@ -155,6 +148,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Prerelease badge */}
|
||||
{isPrerelease && appVersion && (
|
||||
<span
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
|
||||
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
|
||||
{appVersion}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
|
||||
|
||||
@@ -714,6 +714,23 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
|
||||
{/* Disconnected banner — shown when photos exist but provider is unreachable */}
|
||||
{!connected && allVisible.length > 0 && enabledProviders.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 16px', flexShrink: 0,
|
||||
background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.25)',
|
||||
fontSize: 12, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Camera size={13} style={{ color: '#ca8a04', flexShrink: 0 }} />
|
||||
<span>
|
||||
{t('memories.providerDisconnectedBanner', {
|
||||
provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ')
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-010
|
||||
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -99,4 +99,109 @@ describe('InAppNotificationItem', () => {
|
||||
// Recent notification shows "just now"
|
||||
expect(screen.getByText('just now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })}
|
||||
/>
|
||||
);
|
||||
expect(document.querySelector('img')).toBeInTheDocument();
|
||||
expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/avatar.png');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-012: boolean notification shows Accept and Reject buttons', () => {
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
type: 'boolean',
|
||||
positive_text_key: 'common.yes',
|
||||
negative_text_key: 'common.no',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument();
|
||||
expect(screen.getByText('No')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-013: clicking Accept calls respondToBoolean with positive', async () => {
|
||||
const user = userEvent.setup();
|
||||
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useInAppNotificationStore, { respondToBoolean });
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
id: 55,
|
||||
type: 'boolean',
|
||||
positive_text_key: 'common.yes',
|
||||
negative_text_key: 'common.no',
|
||||
response: null,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByText('Yes'));
|
||||
expect(respondToBoolean).toHaveBeenCalledWith(55, 'positive');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-014: clicking Reject calls respondToBoolean with negative', async () => {
|
||||
const user = userEvent.setup();
|
||||
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useInAppNotificationStore, { respondToBoolean });
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
id: 66,
|
||||
type: 'boolean',
|
||||
positive_text_key: 'common.yes',
|
||||
negative_text_key: 'common.no',
|
||||
response: null,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByText('No'));
|
||||
expect(respondToBoolean).toHaveBeenCalledWith(66, 'negative');
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-015: navigate notification shows action button', () => {
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
type: 'navigate',
|
||||
navigate_text_key: 'notifications.title',
|
||||
navigate_target: '/trips/1',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
// t('notifications.title') = "Notifications" — the navigate button renders this
|
||||
const navigateBtn = document.querySelector('button[style*="pointer"]') ??
|
||||
Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('Notifications'));
|
||||
expect(navigateBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-016: clicking navigate button marks read and navigates', async () => {
|
||||
const user = userEvent.setup();
|
||||
const markRead = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
seedStore(useInAppNotificationStore, { markRead });
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({
|
||||
id: 77,
|
||||
type: 'navigate',
|
||||
navigate_text_key: 'notifications.title',
|
||||
navigate_target: '/trips/1',
|
||||
is_read: 0,
|
||||
})}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
// The navigate button renders t('notifications.title') = "Notifications"
|
||||
const btn = Array.from(document.querySelectorAll('button')).find(
|
||||
b => b.textContent?.includes('Notifications')
|
||||
);
|
||||
expect(btn).toBeTruthy();
|
||||
await user.click(btn!);
|
||||
expect(markRead).toHaveBeenCalledWith(77);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// FE-COMP-SCOPE-001 to FE-COMP-SCOPE-009
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import ScopeGroupPicker from './ScopeGroupPicker';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('ScopeGroupPicker', () => {
|
||||
it('FE-COMP-SCOPE-001: renders scope groups', () => {
|
||||
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
// Several group headers should be visible
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-002: shows Select All button when nothing selected', () => {
|
||||
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-003: Select All calls onChange with all scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: /select all/i }));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const called = onChange.mock.calls[0][0] as string[];
|
||||
expect(called.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-004: shows Deselect All button when all selected', async () => {
|
||||
// First collect all scopes by clicking Select All and capturing the callback
|
||||
const user = userEvent.setup();
|
||||
const captured: string[][] = [];
|
||||
const { rerender } = render(
|
||||
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /select all/i }));
|
||||
const allScopes = captured[0];
|
||||
|
||||
// Now rerender with all scopes selected
|
||||
rerender(<ScopeGroupPicker selected={allScopes} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /deselect all/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-005: Deselect All calls onChange with empty array', async () => {
|
||||
const user = userEvent.setup();
|
||||
const captured: string[][] = [];
|
||||
|
||||
// Get all scopes first
|
||||
const { rerender } = render(
|
||||
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /select all/i }));
|
||||
const allScopes = captured[0];
|
||||
|
||||
const onChange = vi.fn();
|
||||
rerender(<ScopeGroupPicker selected={allScopes} onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: /deselect all/i }));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-006: expanding a group reveals individual scope checkboxes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
|
||||
// Groups are collapsed by default — checkboxes for individual scopes not visible
|
||||
const groupToggles = screen.getAllByRole('button').filter(b =>
|
||||
!b.textContent?.toLowerCase().includes('select all') &&
|
||||
!b.textContent?.toLowerCase().includes('deselect all')
|
||||
);
|
||||
// Click the first group expand button
|
||||
await user.click(groupToggles[0]);
|
||||
// Individual scope checkboxes should now appear (more than just group-level ones)
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-007: group checkbox selects all scopes in the group', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
|
||||
const groupCheckboxes = screen.getAllByRole('checkbox');
|
||||
await user.click(groupCheckboxes[0]);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const called = onChange.mock.calls[0][0] as string[];
|
||||
expect(called.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-008: individual scope toggle adds/removes that scope', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
|
||||
// Expand first group
|
||||
const groupToggles = screen.getAllByRole('button').filter(b =>
|
||||
!b.textContent?.toLowerCase().includes('select all') &&
|
||||
!b.textContent?.toLowerCase().includes('deselect all')
|
||||
);
|
||||
await user.click(groupToggles[0]);
|
||||
|
||||
// There are now individual scope checkboxes — click the second one (first is group-level)
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await user.click(checkboxes[1]); // individual scope
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-SCOPE-009: count badge shown when some scopes selected in group', () => {
|
||||
// Get any single scope key from the first group via Select All trick + manual slice
|
||||
// We'll just select a scope by triggering group checkbox and passing it in
|
||||
const firstGroupScope = 'trips:read'; // known scope from SCOPE_GROUPS
|
||||
render(<ScopeGroupPicker selected={[firstGroupScope]} onChange={vi.fn()} />);
|
||||
// Count badge like "(1/N)" should be visible
|
||||
expect(screen.getByText(/\(\d+\/\d+\)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { getScopesByGroup } from '../../api/oauthScopes'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
selected: string[]
|
||||
onChange: (scopes: string[]) => void
|
||||
}
|
||||
|
||||
export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({})
|
||||
|
||||
const scopesByGroup = getScopesByGroup(t)
|
||||
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
|
||||
const allSelected = allScopeKeys.every(s => selected.includes(s))
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-end mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
|
||||
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const groupScopeKeys = groupScopes.map(s => s.scope)
|
||||
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
|
||||
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
|
||||
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
|
||||
style={{ color: 'var(--text-secondary)' }}>
|
||||
{open[group]
|
||||
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
: <ChevronRight className="w-3 h-3 flex-shrink-0" />}
|
||||
{group}
|
||||
{someGroupSelected && (
|
||||
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
|
||||
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={e => onChange(
|
||||
e.target.checked
|
||||
? [...new Set([...selected, ...groupScopeKeys])]
|
||||
: selected.filter(s => !groupScopeKeys.includes(s))
|
||||
)}
|
||||
className="rounded"
|
||||
title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
|
||||
/>
|
||||
</div>
|
||||
{open[group] && (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{groupScopes.map(({ scope, label, description }) => (
|
||||
<label
|
||||
key={scope}
|
||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(scope)}
|
||||
onChange={e => onChange(
|
||||
e.target.checked
|
||||
? [...selected, scope]
|
||||
: selected.filter(s => s !== scope)
|
||||
)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
|
||||
//
|
||||
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
|
||||
// that opens a new browser window and writes a full HTML document into it.
|
||||
// It does NOT render a React component. Tests verify window.open behaviour.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
// Mock `marked` so we don't need the real markdown parser
|
||||
vi.mock('marked', () => ({
|
||||
marked: {
|
||||
parse: (str: string) => `<p>${str}</p>`,
|
||||
},
|
||||
}));
|
||||
|
||||
import { downloadJourneyBookPDF } from './JourneyBookPDF';
|
||||
import type { JourneyDetail } from '../../store/journeyStore';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
title: 'Iceland Ring Road',
|
||||
subtitle: 'Two weeks around the island',
|
||||
status: 'active',
|
||||
cover_image: null,
|
||||
cover_gradient: null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
entries: [
|
||||
{
|
||||
id: 10,
|
||||
journey_id: 1,
|
||||
author_id: 1,
|
||||
type: 'entry',
|
||||
title: 'Golden Circle',
|
||||
story: 'An incredible day of geysers and waterfalls.',
|
||||
entry_date: '2026-07-01',
|
||||
entry_time: '09:00',
|
||||
location_name: 'Thingvellir',
|
||||
location_lat: 64.255,
|
||||
location_lng: -21.13,
|
||||
mood: 'excited',
|
||||
weather: 'sunny',
|
||||
tags: [],
|
||||
pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] },
|
||||
visibility: 'private',
|
||||
sort_order: 0,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
source_trip_id: null,
|
||||
source_place_id: null,
|
||||
source_trip_name: null,
|
||||
photos: [
|
||||
{
|
||||
id: 100,
|
||||
entry_id: 10,
|
||||
provider: 'local',
|
||||
file_path: 'journey/geyser.jpg',
|
||||
thumbnail_path: null,
|
||||
asset_id: null,
|
||||
owner_id: null,
|
||||
shared: 0,
|
||||
caption: 'Strokkur erupting',
|
||||
sort_order: 0,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
trips: [],
|
||||
contributors: [],
|
||||
stats: { entries: 1, photos: 1, cities: 1 },
|
||||
...overrides,
|
||||
} as unknown as JourneyDetail;
|
||||
}
|
||||
|
||||
// ── Mock window.open ─────────────────────────────────────────────────────────
|
||||
|
||||
let mockWindow: {
|
||||
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockWindow = {
|
||||
document: { write: vi.fn(), close: vi.fn() },
|
||||
focus: vi.fn(),
|
||||
};
|
||||
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Golden Circle');
|
||||
// Story text is rendered via markdown
|
||||
expect(html).toContain('An incredible day of geysers and waterfalls.');
|
||||
// Pros/cons verdict cards are included
|
||||
expect(html).toContain('Amazing views');
|
||||
expect(html).toContain('Crowded');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
|
||||
const journey = buildJourney({ entries: [] });
|
||||
await downloadJourneyBookPDF(journey);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
// No entry pages, but cover and closing page are still present
|
||||
expect(html).toContain('Journey Book');
|
||||
expect(html).toContain('The End');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
|
||||
import { marked } from 'marked'
|
||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
function esc(str: string | null | undefined): string {
|
||||
if (!str) return ''
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function md(str: string | null | undefined): string {
|
||||
if (!str) return ''
|
||||
return marked.parse(str, { async: false, breaks: true }) as string
|
||||
}
|
||||
|
||||
function abs(url: string | null | undefined): string {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
|
||||
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
|
||||
}
|
||||
|
||||
function pSrc(p: JourneyPhoto): string {
|
||||
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`)
|
||||
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`)
|
||||
}
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtShort(d: string): string {
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
||||
const groups = new Map<string, JourneyEntry[]>()
|
||||
for (const e of entries) {
|
||||
if (!e.entry_date) continue
|
||||
if (!groups.has(e.entry_date)) groups.set(e.entry_date, [])
|
||||
groups.get(e.entry_date)!.push(e)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function renderProscons(entry: JourneyEntry): string {
|
||||
const pc = entry.pros_cons
|
||||
if (!pc) return ''
|
||||
const pros = pc.pros?.filter(p => p.trim()) || []
|
||||
const cons = pc.cons?.filter(c => c.trim()) || []
|
||||
if (pros.length === 0 && cons.length === 0) return ''
|
||||
|
||||
return `<div class="verdict-wrap"><div class="verdict-row">
|
||||
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">Loved it</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
|
||||
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">Could be better</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
|
||||
</div></div>`
|
||||
}
|
||||
|
||||
function renderPhotoBlock(photos: JourneyPhoto[]): string {
|
||||
if (photos.length === 0) return ''
|
||||
if (photos.length === 1) {
|
||||
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`
|
||||
}
|
||||
if (photos.length === 2) {
|
||||
return `<div class="entry-photo-duo">${photos.map(p => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`
|
||||
}
|
||||
// 3+ photos: hero left + stack right
|
||||
return `<div class="entry-photo-trio">
|
||||
<div class="photo-hero"><img src="${pSrc(photos[0])}" /></div>
|
||||
<div class="photo-stack">
|
||||
<div class="photo-cell"><img src="${pSrc(photos[1])}" /></div>
|
||||
<div class="photo-cell"><img src="${pSrc(photos[2])}" /></div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton' && e.type !== 'gallery')
|
||||
const allPhotos = entries.flatMap(e => e.photos || [])
|
||||
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
|
||||
|
||||
const grouped = groupByDate(entries)
|
||||
const dates = [...grouped.keys()].sort()
|
||||
|
||||
// Build entry pages — one per entry, day header inline on first entry of day
|
||||
const entryPages: string[] = []
|
||||
let pageNum = 1 // cover=1
|
||||
dates.forEach((date, di) => {
|
||||
const dayEntries = grouped.get(date)!
|
||||
dayEntries.forEach((entry, ei) => {
|
||||
pageNum++
|
||||
const isFirstOfDay = ei === 0
|
||||
const photos = entry.photos || []
|
||||
const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ')
|
||||
|
||||
// Day header (inline, only on first entry of day)
|
||||
const dayHeaderHtml = isFirstOfDay
|
||||
? `<div class="day-header">Day ${di + 1} · ${fmtDate(date)}</div>`
|
||||
: ''
|
||||
|
||||
// Photo block
|
||||
const photoHtml = renderPhotoBlock(photos)
|
||||
|
||||
// Pros/cons
|
||||
const prosconsHtml = renderProscons(entry)
|
||||
|
||||
// Story (markdown)
|
||||
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
|
||||
|
||||
entryPages.push(`
|
||||
<div class="entry-page">
|
||||
${dayHeaderHtml}
|
||||
${photoHtml}
|
||||
<div class="entry-content">
|
||||
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
|
||||
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
|
||||
${storyHtml}
|
||||
${prosconsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = pageNum + 1 // +1 for closing page
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<base href="${window.location.origin}/">
|
||||
<title>${esc(journey.title)} — Journey Book</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; color: #1A1A1A; font-size: 11pt; line-height: 1.55; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
|
||||
/* ── Cover ─── */
|
||||
.cover-page {
|
||||
width: 100%; height: 100vh; position: relative; overflow: hidden;
|
||||
background: #0a0a0f; color: white; display: flex; align-items: center; justify-content: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
|
||||
.cover-dim { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
|
||||
.cover-mesh { position: absolute; inset: 0; background: radial-gradient(circle at 20% 30%, rgba(99,102,241,0.2), transparent 50%), radial-gradient(circle at 80% 70%, rgba(236,72,153,0.15), transparent 50%); }
|
||||
.cover-content { position: relative; z-index: 2; text-align: center; padding: 60pt; }
|
||||
.cover-label { font-size: 9pt; font-weight: 700; letter-spacing: 6pt; text-transform: uppercase; opacity: 0.35; margin-bottom: 24pt; }
|
||||
.cover-content h1 { font-size: 56pt; font-weight: 800; letter-spacing: -0.03em; line-height: 0.9; margin-bottom: 10pt; }
|
||||
.cover-content .sub { font-size: 14pt; font-weight: 400; opacity: 0.7; margin-bottom: 36pt; }
|
||||
.cover-stats { display: flex; gap: 48pt; justify-content: center; }
|
||||
.cover-stat-val { font-size: 32pt; font-weight: 800; letter-spacing: -0.02em; }
|
||||
.cover-stat-label { font-size: 10pt; text-transform: uppercase; letter-spacing: 2pt; opacity: 0.4; margin-top: 3pt; }
|
||||
.cover-footer { position: absolute; bottom: 20pt; left: 0; right: 0; text-align: center; font-size: 9pt; opacity: 0.2; letter-spacing: 3pt; text-transform: uppercase; }
|
||||
|
||||
/* ── TOC ─── */
|
||||
.toc-page {
|
||||
width: 100%; height: 100vh; padding: 48pt 64pt; display: flex; flex-direction: column;
|
||||
background: white; page-break-after: always;
|
||||
}
|
||||
.toc-top-label { font-size: 9pt; font-weight: 700; letter-spacing: 5pt; text-transform: uppercase; color: #94a3b8; margin-bottom: 16pt; }
|
||||
.toc-title-block h2 { font-size: 36pt; font-weight: 800; letter-spacing: -1pt; color: #0a0a0f; margin-bottom: 4pt; }
|
||||
.toc-title-block .sub { font-size: 13pt; color: #71717a; margin-bottom: 24pt; }
|
||||
.toc-divider { height: 1pt; background: #e4e4e7; margin: 16pt 0; }
|
||||
.toc-body { flex: 1; columns: 2; column-gap: 40pt; }
|
||||
.toc-day { break-inside: avoid; margin-bottom: 14pt; }
|
||||
.toc-day-label { font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: #71717a; margin-bottom: 4pt; }
|
||||
.toc-entry { display: flex; align-items: baseline; gap: 4pt; font-size: 11pt; color: #3f3f46; margin-bottom: 2pt; }
|
||||
.toc-entry .toc-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200pt; }
|
||||
.toc-entry .toc-dots { flex: 1; border-bottom: 1pt dotted #d4d4d8; margin: 0 4pt; min-width: 20pt; }
|
||||
.toc-entry .toc-page { font-size: 10pt; color: #a1a1aa; font-weight: 500; flex-shrink: 0; }
|
||||
.toc-stats { display: flex; gap: 32pt; margin-top: auto; padding-top: 16pt; border-top: 1pt solid #e4e4e7; }
|
||||
.toc-stat-val { font-size: 18pt; font-weight: 800; color: #0a0a0f; }
|
||||
.toc-stat-label { font-size: 9pt; text-transform: uppercase; letter-spacing: 1pt; color: #94a3b8; }
|
||||
|
||||
/* ── Entry Page ─── */
|
||||
.entry-page {
|
||||
width: 100%; min-height: 100vh; padding: 56pt 48pt 48pt;
|
||||
page-break-after: always;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
/* Day header — inline */
|
||||
.day-header {
|
||||
font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: #71717a; text-align: center; margin-bottom: 16pt; position: relative;
|
||||
display: flex; align-items: center; gap: 12pt;
|
||||
}
|
||||
.day-header::before, .day-header::after { content: ''; flex: 1; height: 0.5pt; background: #d4d4d8; }
|
||||
|
||||
/* Photos */
|
||||
.entry-photo-single { border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 55vh; }
|
||||
.entry-photo-single img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.entry-photo-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 45vh; }
|
||||
.entry-photo-trio { display: grid; grid-template-columns: 3fr 2fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 50vh; }
|
||||
.photo-cell { overflow: hidden; }
|
||||
.photo-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.photo-hero { overflow: hidden; }
|
||||
.photo-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.photo-stack { display: flex; flex-direction: column; gap: 6pt; }
|
||||
.photo-stack .photo-cell { flex: 1; }
|
||||
|
||||
/* Entry content */
|
||||
.entry-content { flex: 1; }
|
||||
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
|
||||
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
|
||||
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
|
||||
.entry-story p { margin: 0 0 8pt; }
|
||||
.entry-story strong { font-weight: 600; color: #0a0a0f; }
|
||||
.entry-story em { font-style: italic; }
|
||||
.entry-story blockquote { margin: 12pt 0; padding-left: 12pt; border-left: 2pt solid #d4d4d8; font-style: italic; color: #52525b; }
|
||||
.entry-story ul, .entry-story ol { margin: 8pt 0; padding-left: 16pt; }
|
||||
.entry-story li { margin-bottom: 4pt; }
|
||||
.entry-story a { color: #2563eb; text-decoration: none; }
|
||||
|
||||
/* Verdict */
|
||||
.verdict-wrap { break-inside: avoid; padding-top: 14pt; }
|
||||
.verdict-row { display: flex; gap: 10pt; }
|
||||
.verdict-card { flex: 1; padding: 10pt 12pt; border-radius: 6pt; font-size: 9.5pt; }
|
||||
.verdict-card.pros { background: #f0fdf4; border: 0.5pt solid #bbf7d0; }
|
||||
.verdict-card.cons { background: #fef2f2; border: 0.5pt solid #fecaca; }
|
||||
.verdict-label { font-size: 8pt; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6pt; }
|
||||
.verdict-card.pros .verdict-label { color: #15803d; }
|
||||
.verdict-card.cons .verdict-label { color: #b91c1c; }
|
||||
.verdict-card ul { margin: 0; padding: 0; list-style: none; }
|
||||
.verdict-card li { padding: 2pt 0; position: relative; padding-left: 10pt; }
|
||||
.verdict-card li::before { content: '•'; position: absolute; left: 0; }
|
||||
.verdict-card.pros li { color: #14532d; }
|
||||
.verdict-card.pros li::before { color: #22c55e; }
|
||||
.verdict-card.cons li { color: #7f1d1d; }
|
||||
.verdict-card.cons li::before { color: #ef4444; }
|
||||
|
||||
/* ── Closing ─── */
|
||||
.closing-page {
|
||||
width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: #0a0a0f; color: white; text-align: center; page-break-after: auto;
|
||||
}
|
||||
.closing-title { font-size: 32pt; font-weight: 300; letter-spacing: -1pt; opacity: 0.6; margin-bottom: 8pt; }
|
||||
.closing-sub { font-size: 10pt; opacity: 0.25; letter-spacing: 3pt; text-transform: uppercase; }
|
||||
|
||||
/* ── Print ─── */
|
||||
@media print {
|
||||
.print-bar { display: none !important; }
|
||||
body { margin: 0; }
|
||||
.entry-page { orphans: 3; widows: 3; }
|
||||
h2.entry-title { page-break-after: avoid; }
|
||||
.verdict-row { page-break-inside: avoid; }
|
||||
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
|
||||
}
|
||||
|
||||
.print-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
|
||||
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
|
||||
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
|
||||
.print-bar .btn-save { background: white; color: #0f172a; }
|
||||
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-bar">
|
||||
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<button class="btn-save" onclick="window.print()">Save as PDF</button>
|
||||
<button class="btn-close" onclick="window.close()">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Page 1: Cover -->
|
||||
<div class="cover-page">
|
||||
${coverUrl ? `<div class="cover-bg" style="background-image:url('${coverUrl}')"></div>` : ''}
|
||||
<div class="cover-dim"></div>
|
||||
<div class="cover-mesh"></div>
|
||||
<div class="cover-content">
|
||||
<div class="cover-label">Journey Book</div>
|
||||
<h1>${esc(journey.title)}</h1>
|
||||
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
|
||||
<div class="cover-stats">
|
||||
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">Days</div></div>
|
||||
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">Entries</div></div>
|
||||
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">Photos</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cover-footer">Made with TREK</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry Pages -->
|
||||
${entryPages.join('\n')}
|
||||
|
||||
<!-- Closing Page -->
|
||||
<div class="closing-page">
|
||||
<div>
|
||||
<div class="closing-title">The End</div>
|
||||
<div class="closing-sub">Made with TREK · ${new Date().getFullYear()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) return
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
}
|
||||
@@ -467,6 +467,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
const [showAddItem, setShowAddItem] = useState(false)
|
||||
const [newItemName, setNewItemName] = useState('')
|
||||
const addItemRef = useRef<HTMLInputElement>(null)
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { togglePackingItem } = useTripStore()
|
||||
const toast = useToast()
|
||||
@@ -629,22 +630,27 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</span>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
<button ref={menuBtnRef} onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
|
||||
onMouseLeave={() => setShowMenu(false)}>
|
||||
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
{canEdit && <>
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
{showMenu && (() => {
|
||||
const rect = menuBtnRef.current?.getBoundingClientRect();
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowMenu(false)} />
|
||||
<div style={{ position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 4 : 0, zIndex: 100, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}>
|
||||
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
{canEdit && <>
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
</>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
value={caption}
|
||||
onChange={e => setCaption(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()}
|
||||
placeholder="Beschriftung hinzufügen..."
|
||||
placeholder={t('photos.addCaption')}
|
||||
className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -173,7 +173,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
|
||||
onClick={() => setEditCaption(true)}
|
||||
>
|
||||
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>}
|
||||
{photo.caption || <span className="text-white/40 italic">{t('photos.addCaption')}</span>}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setEditCaption(true)}
|
||||
|
||||
@@ -43,15 +43,15 @@ describe('PhotoUpload', () => {
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
expect(screen.getByText('Fotos hier ablegen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Drop photos here')).toBeInTheDocument()
|
||||
// Upload icon rendered via lucide-react as SVG
|
||||
expect(document.querySelector('svg')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
expect(screen.queryByText('Tag verknüpfen')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('Optionale Beschriftung...')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Link Day')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('Optional caption...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => {
|
||||
@@ -65,27 +65,27 @@ describe('PhotoUpload', () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
await uploadFiles([makeFile()])
|
||||
expect(screen.getByAltText('photo.jpg')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tag verknüpfen')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Optionale Beschriftung...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Link Day')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Optional caption...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
|
||||
expect(screen.getByText('2 Fotos ausgewählt')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-006: remove button removes a file from preview', async () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
|
||||
expect(screen.getByText('2 Fotos ausgewählt')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
|
||||
|
||||
// Remove buttons are inside `.relative.aspect-square` wrappers in the preview grid
|
||||
const removeButtons = document.querySelectorAll('.relative.aspect-square button')
|
||||
expect(removeButtons.length).toBe(2)
|
||||
await userEvent.click(removeButtons[0])
|
||||
|
||||
expect(screen.getByText('1 Foto ausgewählt')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 Photo selected')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('img').length).toBe(1)
|
||||
})
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('PhotoUpload', () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
await uploadFiles([makeFile()])
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Optionale Beschriftung...'), 'Vacation')
|
||||
await userEvent.type(screen.getByPlaceholderText('Optional caption...'), 'Vacation')
|
||||
|
||||
await userEvent.click(getSubmitButton())
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('PhotoUpload', () => {
|
||||
await userEvent.click(getSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/wird hochgeladen/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/uploading/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(getSubmitButton()).toBeDisabled()
|
||||
|
||||
@@ -85,12 +85,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
<input {...getInputProps()} />
|
||||
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
|
||||
{isDragActive ? (
|
||||
<p className="text-slate-700 font-medium">Fotos hier ablegen...</p>
|
||||
<p className="text-slate-700 font-medium">{t('photos.dropHere')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600 font-medium">Fotos hier ablegen</p>
|
||||
<p className="text-gray-600 font-medium">{t('photos.dropHereActive')}</p>
|
||||
<p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p>
|
||||
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
|
||||
<p className="text-gray-400 text-xs mt-2">{t('photos.fileTypeHint')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
{/* Preview grid */}
|
||||
{files.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} Foto{files.length !== 1 ? 's' : ''} ausgewählt</p>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} {t(files.length !== 1 ? 'photos.photosSelected' : 'photos.photoSelected')}</p>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto">
|
||||
{files.map((file, idx) => (
|
||||
<div key={idx} className="relative aspect-square group">
|
||||
@@ -126,15 +126,15 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
{files.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Tag verknüpfen</label>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkDay')}</label>
|
||||
<select
|
||||
value={dayId}
|
||||
onChange={e => setDayId(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
>
|
||||
<option value="">Kein Tag</option>
|
||||
<option value="">{t('photos.noDay')}</option>
|
||||
{(days || []).map(day => (
|
||||
<option key={day.id} value={day.id}>Tag {day.day_number}</option>
|
||||
<option key={day.id} value={day.id}>{t('photos.dayLabel', { number: day.day_number })}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -152,12 +152,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Beschriftung (für alle)</label>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.captionForAll')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={caption}
|
||||
onChange={e => setCaption(e.target.value)}
|
||||
placeholder="Optionale Beschriftung..."
|
||||
placeholder={t('photos.captionPlaceholder')}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-slate-900">Wird hochgeladen...</span>
|
||||
<span className="text-sm text-slate-900">{t('common.uploading')}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1.5">
|
||||
<div
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}>
|
||||
<div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
@@ -189,7 +189,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</div>
|
||||
{!collapsed && formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>}
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? 'Expand' : 'Collapse'}
|
||||
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
|
||||
style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, transition: 'all 0.15s ease' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
|
||||
|
||||
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
@@ -55,6 +55,99 @@ const TYPE_ICONS = {
|
||||
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
|
||||
}
|
||||
|
||||
function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onAddNew }: {
|
||||
dayId: number
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
onAssign?: (placeId: number, dayId: number) => void
|
||||
onAddNew?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// Find places not assigned to this day
|
||||
const assignedToDay = new Set((assignments[String(dayId)] || []).map(a => a.place_id))
|
||||
const available = places.filter(p => !assignedToDay.has(p.id))
|
||||
const filtered = search.trim()
|
||||
? available.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: available
|
||||
|
||||
return (
|
||||
<div className="md:hidden" style={{ padding: '8px 12px 12px' }}>
|
||||
{!open ? (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setOpen(true) }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '10px 0', borderRadius: 12,
|
||||
border: '1.5px dashed var(--border-primary)',
|
||||
background: 'transparent', color: 'var(--text-muted)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Place
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ borderRadius: 14, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px 10px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('dayplan.mobile.searchPlaces')}
|
||||
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={() => { setOpen(false); setSearch('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: '16px 12px', textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||
{available.length === 0 ? t('dayplan.mobile.allAssigned') : t('dayplan.mobile.noMatch')}
|
||||
</div>
|
||||
)}
|
||||
{filtered.slice(0, 20).map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
onAssign?.(p.id, dayId)
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px', border: 'none', background: 'transparent',
|
||||
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<MapPin size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{onAddNew && (
|
||||
<button
|
||||
onClick={() => { onAddNew(); setOpen(false); setSearch('') }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '10px 0', borderTop: '1px solid var(--border-faint)',
|
||||
background: 'transparent', border: 'none', color: 'var(--text-muted)',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Create new place
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DayPlanSidebarProps {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
@@ -79,6 +172,8 @@ interface DayPlanSidebarProps {
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
onNavigateToFiles?: () => void
|
||||
onAddPlace?: () => void
|
||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
canUndo?: boolean
|
||||
@@ -95,6 +190,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onNavigateToFiles,
|
||||
onExpandedDaysChange,
|
||||
pushUndo,
|
||||
@@ -519,7 +616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
@@ -606,7 +703,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
tripActions.setAssignments(currentAssignments)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -755,9 +852,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId && fromDayId !== dayId) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
@@ -862,7 +959,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('ICS export failed') }
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
onMouseEnter={() => setIcsHover(true)}
|
||||
onMouseLeave={() => setIcsHover(false)}
|
||||
@@ -1089,11 +1186,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (assignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
|
||||
}
|
||||
@@ -1107,11 +1204,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -1207,7 +1304,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
@@ -1215,7 +1312,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
@@ -1227,7 +1324,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
@@ -1411,11 +1508,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
|
||||
}
|
||||
@@ -1499,7 +1596,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
@@ -1507,7 +1604,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
@@ -1572,11 +1669,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -1623,6 +1720,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: Add Place from list */}
|
||||
<MobileAddPlaceButton
|
||||
dayId={day.id}
|
||||
places={places}
|
||||
assignments={assignments}
|
||||
onAssign={onAssignToDay}
|
||||
onAddNew={onAddPlace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -236,6 +236,7 @@ export default function PlaceFormModal({
|
||||
onChange={e => setMapsSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())}
|
||||
placeholder={t('places.mapsSearchPlaceholder')}
|
||||
autoFocus
|
||||
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -601,7 +601,7 @@ export default function PlaceInspector({
|
||||
{selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">Remove</span></>} />
|
||||
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">{t('inspector.remove')}</span></>} />
|
||||
) : (
|
||||
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
||||
)
|
||||
@@ -611,7 +611,7 @@ export default function PlaceInspector({
|
||||
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
||||
)}
|
||||
{!googleDetails?.google_maps_url && place.lat && place.lng && (
|
||||
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||
label={<span className="hidden sm:inline">Google Maps</span>} />
|
||||
)}
|
||||
{(place.website || googleDetails?.website) && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { placesApi } from '../../api/client';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -443,11 +444,8 @@ describe('GPX import', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/places/import/gpx', () =>
|
||||
HttpResponse.json({ count: 2, places: [{ id: 10 }, { id: 11 }] })
|
||||
),
|
||||
);
|
||||
// FormData POST hangs on CI — mock at the API boundary instead of MSW.
|
||||
const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
|
||||
const loadTrip = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useTripStore, { loadTrip });
|
||||
const addToast = vi.fn();
|
||||
@@ -465,6 +463,7 @@ describe('GPX import', () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
importSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
@@ -63,7 +63,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -94,7 +94,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -124,7 +124,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -154,7 +154,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -184,7 +184,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('blur_booking_codes', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-018
|
||||
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-032
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import IntegrationsTab from './IntegrationsTab';
|
||||
|
||||
function enableMcp() {
|
||||
@@ -40,6 +41,8 @@ beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
|
||||
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -69,18 +72,26 @@ describe('IntegrationsTab', () => {
|
||||
expect(codeEl!.textContent).toContain('/mcp');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered', async () => {
|
||||
it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered when expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
// Config is collapsed by default — no <pre> yet
|
||||
expect(document.querySelector('pre')).toBeNull();
|
||||
// Expand by clicking the "Client Configuration" toggle
|
||||
await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
|
||||
const preEl = document.querySelector('pre');
|
||||
expect(preEl).not.toBeNull();
|
||||
expect(preEl!.textContent).toContain('mcpServers');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('No tokens yet. Create one to connect MCP clients.');
|
||||
});
|
||||
|
||||
@@ -95,8 +106,11 @@ describe('IntegrationsTab', () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('My Token');
|
||||
await screen.findByText('Other Token');
|
||||
});
|
||||
@@ -106,6 +120,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
const createBtn = screen.getByRole('button', { name: /Create New Token/i });
|
||||
await user.click(createBtn);
|
||||
await screen.findByText('Create API Token');
|
||||
@@ -116,6 +131,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
|
||||
@@ -127,6 +143,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
@@ -153,6 +170,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
@@ -182,6 +200,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test');
|
||||
@@ -206,6 +225,8 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
|
||||
@@ -230,6 +251,8 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal
|
||||
@@ -289,6 +312,8 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Cancel Token');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await screen.findByRole('button', { name: /^Cancel$/i });
|
||||
@@ -319,6 +344,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
@@ -328,4 +354,301 @@ describe('IntegrationsTab', () => {
|
||||
expect(postCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-019: default tab is OAuth 2.1 Clients — OAuth hint visible, token list hidden', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
// OAuth hint is visible on the default tab
|
||||
expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
|
||||
// API Tokens "no tokens" message is not rendered
|
||||
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-020: switching tabs toggles content visibility', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
// Default: OAuth hint visible, token list absent
|
||||
expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
|
||||
// Switch to API Tokens tab
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('No tokens yet. Create one to connect MCP clients.');
|
||||
expect(screen.queryByText(/Register OAuth 2\.1 clients/i)).toBeNull();
|
||||
// Switch back to OAuth tab
|
||||
await user.click(screen.getByRole('button', { name: /OAuth 2\.1 Clients/i }));
|
||||
await screen.findByText(/Register OAuth 2\.1 clients/i);
|
||||
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-021: OAuth client list renders when clients exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
clients: [
|
||||
{
|
||||
id: 'client-1',
|
||||
client_id: 'clid-abc',
|
||||
name: 'My OAuth App',
|
||||
redirect_uris: ['http://localhost'],
|
||||
allowed_scopes: ['trips:read', 'places:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('My OAuth App');
|
||||
expect(screen.getByText(/clid-abc/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-022: scope expansion toggle shows more/fewer scopes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
clients: [
|
||||
{ id: 'c1', client_id: 'cid', name: 'Big App', redirect_uris: ['http://localhost'], allowed_scopes: scopes, created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Big App');
|
||||
// "+2 more" button visible (7 scopes, 5 shown)
|
||||
const moreBtn = screen.getByText(/^\+\d+$/);
|
||||
await user.click(moreBtn);
|
||||
// Show less / collapse button now visible
|
||||
expect(screen.getByText('−')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-023: active OAuth sessions section renders when sessions exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/oauth/sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{
|
||||
id: 10,
|
||||
client_name: 'Claude Desktop',
|
||||
scopes: ['trips:read'],
|
||||
access_token_expires_at: '2025-12-31T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Claude Desktop');
|
||||
expect(screen.getByText(/trips:read/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-024: Create OAuth Client modal opens and shows presets', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
expect(screen.getByText('Claude.ai')).toBeInTheDocument();
|
||||
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-025: clicking a preset fills form fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
// Presets render as buttons — click "Claude.ai" preset
|
||||
const presetBtns = screen.getAllByRole('button', { name: /Claude\.ai/i });
|
||||
await user.click(presetBtns[0]);
|
||||
// Name field should be filled with 'Claude.ai'
|
||||
const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
|
||||
expect((nameInput as HTMLInputElement).value).toBe('Claude.ai');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-026: creating client shows success view with client_id and secret', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
client: {
|
||||
id: 'new-id',
|
||||
client_id: 'clid-new',
|
||||
client_secret: 'secret-value',
|
||||
name: 'Test Client',
|
||||
redirect_uris: ['http://localhost'],
|
||||
allowed_scopes: ['trips:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
|
||||
await user.type(nameInput, 'Test Client');
|
||||
const uriInput = screen.getByPlaceholderText(/https:\/\/your-app/i);
|
||||
await user.type(uriInput, 'http://localhost');
|
||||
await user.click(screen.getByRole('button', { name: /Register Client/i }));
|
||||
// Success view shows client credentials (there may be multiple matches in list + modal)
|
||||
await screen.findAllByText(/clid-new/);
|
||||
const secretEls = await screen.findAllByText(/secret-value/);
|
||||
expect(secretEls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-027: Done button closes created-client modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
client: {
|
||||
id: 'n2',
|
||||
client_id: 'clid-n2',
|
||||
client_secret: 'secret-n2',
|
||||
name: 'TC2',
|
||||
redirect_uris: ['http://localhost'],
|
||||
allowed_scopes: ['trips:read'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'TC2');
|
||||
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
|
||||
await user.click(screen.getByRole('button', { name: /Register Client/i }));
|
||||
await screen.findAllByText(/clid-n2/);
|
||||
// Check the "Client Registered" modal title is visible before Done
|
||||
expect(screen.getByText('Client Registered')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /^Done$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Client Registered')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-028: delete OAuth client confirmation removes client from list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
clients: [
|
||||
{ id: 'del-1', client_id: 'cid-del', name: 'Delete Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/oauth/clients/del-1', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Client'));
|
||||
// Confirmation modal
|
||||
await screen.findByRole('heading', { name: 'Delete Client' });
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /Delete Client/i });
|
||||
// Modal confirm button is last in DOM (modal renders after list)
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete Me')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-029: rotate secret confirmation shows new secret', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/oauth/clients', () =>
|
||||
HttpResponse.json({
|
||||
clients: [
|
||||
{ id: 'rot-1', client_id: 'cid-rot', name: 'Rotate Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/oauth/clients/rot-1/rotate', () =>
|
||||
HttpResponse.json({ client_secret: 'new-rotated-secret' })
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('Rotate Me');
|
||||
await user.click(screen.getByTitle('Rotate Secret'));
|
||||
await screen.findByText('Rotate Secret');
|
||||
// Confirm — button text is 'Rotate'
|
||||
const rotateBtns = screen.getAllByRole('button', { name: /^Rotate$/i });
|
||||
await user.click(rotateBtns[rotateBtns.length - 1]);
|
||||
await screen.findByText(/new-rotated-secret/);
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-030: revoke OAuth session removes it from list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/oauth/sessions', () =>
|
||||
HttpResponse.json({
|
||||
sessions: [
|
||||
{ id: 99, client_name: 'Revoke App', scopes: ['trips:read'], access_token_expires_at: '2025-12-31T00:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/oauth/sessions/99', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('Revoke App');
|
||||
await user.click(screen.getByText('Revoke'));
|
||||
// Confirmation modal
|
||||
await screen.findByText('Revoke Session');
|
||||
const revokeBtns = screen.getAllByRole('button', { name: /^Revoke$/i });
|
||||
// Modal confirm button is last in DOM
|
||||
await user.click(revokeBtns[revokeBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Revoke App')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-031: Register Client button disabled when name or URI is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
const createBtn = screen.getByRole('button', { name: /Register Client/i });
|
||||
expect(createBtn).toBeDisabled();
|
||||
// Type only name, not URI → still disabled
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Test');
|
||||
expect(createBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-032: error toast shown when create OAuth client fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/oauth/clients', () =>
|
||||
HttpResponse.json({ error: 'server error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
enableMcp();
|
||||
render(<><ToastContainer /><IntegrationsTab /></>);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /New Client/i }));
|
||||
await screen.findByText('Register OAuth Client');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
|
||||
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
|
||||
await user.click(screen.getByRole('button', { name: /Register Client/i }));
|
||||
await screen.findByText(/Failed to register/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,87 @@
|
||||
import Section from './Section'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react'
|
||||
import { authApi } from '../../api/client'
|
||||
import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
|
||||
import { authApi, oauthApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
|
||||
interface OAuthPreset {
|
||||
id: string
|
||||
label: string
|
||||
name: string
|
||||
uris: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
const OAUTH_PRESETS: OAuthPreset[] = [
|
||||
{
|
||||
id: 'claude-web',
|
||||
label: 'Claude.ai',
|
||||
name: 'Claude.ai',
|
||||
uris: 'https://claude.ai/api/mcp/auth_callback',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
label: 'Claude Desktop',
|
||||
name: 'Claude Desktop',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
name: 'Cursor',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
label: 'VS Code',
|
||||
name: 'VS Code / Copilot',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => s.endsWith(':read')),
|
||||
},
|
||||
{
|
||||
id: 'windsurf',
|
||||
label: 'Windsurf',
|
||||
name: 'Windsurf',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
label: 'Zed',
|
||||
name: 'Zed',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
interface OAuthClient {
|
||||
id: string
|
||||
name: string
|
||||
client_id: string
|
||||
redirect_uris: string[]
|
||||
allowed_scopes: string[]
|
||||
created_at: string
|
||||
client_secret?: string // only present on create
|
||||
}
|
||||
|
||||
interface OAuthSession {
|
||||
id: number
|
||||
client_id: string
|
||||
client_name: string
|
||||
scopes: string[]
|
||||
access_token_expires_at: string
|
||||
refresh_token_expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface McpToken {
|
||||
id: number
|
||||
@@ -26,6 +101,28 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
loadAddons()
|
||||
}, [loadAddons])
|
||||
|
||||
// OAuth clients state
|
||||
const [oauthClients, setOauthClients] = useState<OAuthClient[]>([])
|
||||
const [oauthSessions, setOauthSessions] = useState<OAuthSession[]>([])
|
||||
const [oauthCreateOpen, setOauthCreateOpen] = useState(false)
|
||||
const [oauthNewName, setOauthNewName] = useState('')
|
||||
const [oauthNewUris, setOauthNewUris] = useState('')
|
||||
const [oauthNewScopes, setOauthNewScopes] = useState<string[]>([])
|
||||
const [oauthCreating, setOauthCreating] = useState(false)
|
||||
const [oauthCreatedClient, setOauthCreatedClient] = useState<OAuthClient | null>(null)
|
||||
const [oauthDeleteId, setOauthDeleteId] = useState<string | null>(null)
|
||||
const [oauthRevokeId, setOauthRevokeId] = useState<number | null>(null)
|
||||
const [oauthRotateId, setOauthRotateId] = useState<string | null>(null)
|
||||
const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null)
|
||||
const [oauthRotating, setOauthRotating] = useState(false)
|
||||
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
||||
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
// MCP sub-tab state
|
||||
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
||||
const [configOpenOAuth, setConfigOpenOAuth] = useState(false)
|
||||
const [configOpenToken, setConfigOpenToken] = useState(false)
|
||||
|
||||
// MCP state
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
@@ -34,8 +131,26 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const [mcpCreating, setMcpCreating] = useState(false)
|
||||
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
|
||||
}, [])
|
||||
|
||||
const mcpEndpoint = `${window.location.origin}/mcp`
|
||||
const mcpJsonConfigOAuth = `{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${mcpEndpoint}",
|
||||
"--static-oauth-client-info",
|
||||
"{\\"client_id\\": \\"<your_client_id>\\", \\"client_secret\\": \\"<your_client_secret>\\"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
const mcpJsonConfig = `{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
@@ -85,10 +200,72 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const handleCopy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
// Load OAuth clients and sessions
|
||||
useEffect(() => {
|
||||
if (mcpEnabled) {
|
||||
oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {})
|
||||
oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {})
|
||||
}
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateOAuthClient = async () => {
|
||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
||||
setOauthCreating(true)
|
||||
try {
|
||||
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
|
||||
setOauthCreatedClient(d.client)
|
||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||
setOauthNewName('')
|
||||
setOauthNewUris('')
|
||||
setOauthNewScopes([])
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.createError'))
|
||||
} finally {
|
||||
setOauthCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteOAuthClient = async (id: string) => {
|
||||
try {
|
||||
await oauthApi.clients.delete(id)
|
||||
setOauthClients(prev => prev.filter(c => c.id !== id))
|
||||
setOauthDeleteId(null)
|
||||
toast.success(t('settings.oauth.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRotateSecret = async (id: string) => {
|
||||
setOauthRotating(true)
|
||||
try {
|
||||
const d = await oauthApi.clients.rotate(id)
|
||||
setOauthRotatedSecret(d.client_secret)
|
||||
setOauthRotateId(null)
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.rotateError'))
|
||||
} finally {
|
||||
setOauthRotating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevokeSession = async (id: number) => {
|
||||
try {
|
||||
await oauthApi.sessions.revoke(id)
|
||||
setOauthSessions(prev => prev.filter(s => s.id !== id))
|
||||
setOauthRevokeId(null)
|
||||
toast.success(t('settings.oauth.toast.revoked'))
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.revokeError'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
@@ -109,63 +286,217 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON config box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
|
||||
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpJsonConfig}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||
{/* Sub-tab bar */}
|
||||
<div className="flex gap-1 rounded-lg p-1" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
<button
|
||||
onClick={() => setActiveMcpTab('oauth')}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
activeMcpTab === 'oauth' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}>
|
||||
{t('settings.oauth.clients')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveMcpTab('apitokens')}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2 ${
|
||||
activeMcpTab === 'apitokens' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}>
|
||||
{t('settings.mcp.apiTokens')}
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
style={{ background: 'rgba(245,158,11,0.15)', color: '#b45309', border: '1px solid rgba(245,158,11,0.4)' }}>
|
||||
Deprecated
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpTokens.length === 0 ? (
|
||||
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
</p>
|
||||
) : (
|
||||
{/* OAuth 2.1 Clients tab */}
|
||||
{activeMcpTab === 'oauth' && (
|
||||
<>
|
||||
{/* JSON config — OAuth (collapsible) */}
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setConfigOpenOAuth(o => !o)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
|
||||
{configOpenOAuth ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
|
||||
</button>
|
||||
{configOpenOAuth && (
|
||||
<div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex justify-end mb-1.5">
|
||||
<button onClick={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json-oauth' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json-oauth' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpJsonConfigOAuth}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHintOAuth')}</p>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
||||
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{oauthClients.length === 0 ? (
|
||||
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||
{t('settings.oauth.noClients')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{oauthClients.map((client, i) => (
|
||||
<div key={client.id} className="px-4 py-3"
|
||||
style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.clientId')}: {client.client_id}
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
|
||||
<span key={s} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>{s}</span>
|
||||
))}
|
||||
{client.allowed_scopes.length > 5 && (
|
||||
<button
|
||||
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
|
||||
className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
||||
{oauthScopesExpanded[client.id] ? '−' : `+${client.allowed_scopes.length - 5}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setOauthRotateId(client.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.rotateSecret')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setOauthDeleteId(client.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.deleteClient')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active OAuth Sessions */}
|
||||
{oauthSessions.length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label>
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{oauthSessions.map((session, i) => (
|
||||
<div key={session.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
|
||||
<span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setOauthRevokeId(session.id)}
|
||||
className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* API Tokens tab (deprecated) */}
|
||||
{activeMcpTab === 'apitokens' && (
|
||||
<>
|
||||
<div className="flex items-baseline gap-2 px-3 py-2.5 rounded-lg" style={{ background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.3)' }}>
|
||||
<span className="text-amber-500 flex-shrink-0 leading-none">⚠</span>
|
||||
<p className="text-xs" style={{ color: '#92400e' }}>{t('settings.mcp.apiTokensDeprecated')}</p>
|
||||
</div>
|
||||
|
||||
{/* JSON config — API Token (collapsible) */}
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<button
|
||||
onClick={() => setConfigOpenToken(o => !o)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
|
||||
{configOpenToken ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
|
||||
</button>
|
||||
{configOpenToken && (
|
||||
<div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex justify-end mb-1.5">
|
||||
<button onClick={() => handleCopy(mcpJsonConfig, 'json-token')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json-token' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json-token' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpJsonConfig}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60"
|
||||
style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpTokens.length === 0 ? (
|
||||
<p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -182,7 +513,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
@@ -192,8 +523,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -217,8 +547,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -248,6 +577,216 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create OAuth Client modal */}
|
||||
{oauthCreateOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-lg p-6 space-y-4 overflow-y-auto max-h-[90vh]" style={{ background: 'var(--bg-card)' }}>
|
||||
{!oauthCreatedClient ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createTitle')}</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.presets')}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{OAUTH_PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOauthNewName(preset.name)
|
||||
setOauthNewUris(preset.uris)
|
||||
setOauthNewScopes(preset.scopes)
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-md text-xs font-medium border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-secondary)' }}>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.clientName')}</label>
|
||||
<input type="text" value={oauthNewName} onChange={e => setOauthNewName(e.target.value)}
|
||||
placeholder={t('settings.oauth.modal.clientNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
||||
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
||||
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p>
|
||||
<ScopeGroupPicker selected={oauthNewScopes} onChange={setOauthNewScopes} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button onClick={() => setOauthCreateOpen(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateOAuthClient}
|
||||
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createdTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.createdWarning')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientId')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{oauthCreatedClient.client_id}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(oauthCreatedClient.client_id, 'new-client-id')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{oauthCreatedClient.client_secret}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(oauthCreatedClient.client_secret!, 'new-client-secret')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete OAuth Client confirm */}
|
||||
{oauthDeleteId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthDeleteId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.deleteClient')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.deleteClientMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setOauthDeleteId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteOAuthClient(oauthDeleteId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.oauth.deleteClient')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rotate OAuth Client Secret confirm */}
|
||||
{oauthRotateId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthRotateId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecret')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setOauthRotateId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||
{oauthRotating ? t('settings.oauth.rotateSecretConfirming') : t('settings.oauth.rotateSecretConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rotated Secret display */}
|
||||
{oauthRotatedSecret !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecretDoneTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretDoneWarning')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{oauthRotatedSecret}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(oauthRotatedSecret, 'rotated-secret')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => setOauthRotatedSecret(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke OAuth Session confirm */}
|
||||
{oauthRevokeId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setOauthRevokeId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.revokeSession')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.revokeSessionMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setOauthRevokeId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRevokeSession(oauthRevokeId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.oauth.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
})
|
||||
toast.success(t('settings.toast.mapSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ describe('NotificationsTab', () => {
|
||||
http.get('/api/notifications/preferences', () => new Promise(() => {})),
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => {
|
||||
render(<NotificationsTab />);
|
||||
// The event label is translated; fallback is the key itself
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
// Should render a toggle (ToggleSwitch renders a button)
|
||||
const toggles = await screen.findAllByRole('button');
|
||||
@@ -52,7 +52,7 @@ describe('NotificationsTab', () => {
|
||||
it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => {
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
// inapp channel header should appear (either translated or raw key)
|
||||
const headers = screen.getAllByText(/inapp|in.?app/i);
|
||||
@@ -72,7 +72,7 @@ describe('NotificationsTab', () => {
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
// Should show noChannels message (translated or key)
|
||||
const noChannelEl = await screen.findByText(/no.*channel|noChannels/i);
|
||||
@@ -97,7 +97,7 @@ describe('NotificationsTab', () => {
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
// A dash should appear for non-implemented combos
|
||||
const dashes = await screen.findAllByText('—');
|
||||
@@ -116,7 +116,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// minimalMatrix has inapp:true and email:false for trip_invite
|
||||
@@ -144,7 +144,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the inapp toggle for trip_invite — it starts as "on"
|
||||
@@ -156,8 +156,8 @@ describe('NotificationsTab', () => {
|
||||
|
||||
// After the error, the toggle should revert back (still rendered in the DOM)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The toggle should still be present (not removed on error)
|
||||
@@ -178,20 +178,20 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggleButtons = await screen.findAllByRole('button');
|
||||
await user.click(toggleButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Saving…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
resolveRequest();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Webhook URL input should be present
|
||||
@@ -238,7 +238,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
@@ -265,7 +265,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
@@ -297,7 +297,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await screen.findByRole('textbox');
|
||||
@@ -330,7 +330,7 @@ describe('NotificationsTab', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
@@ -371,7 +371,7 @@ describe('NotificationsTab', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading…</p>
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
|
||||
|
||||
if (visibleChannels.length === 0) {
|
||||
return (
|
||||
@@ -119,7 +119,7 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('common.saving')}</p>}
|
||||
{matrix.available_channels.webhook && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ProviderField {
|
||||
label: string
|
||||
input_type: string
|
||||
placeholder?: string | null
|
||||
hint?: string | null
|
||||
required: boolean
|
||||
secret: boolean
|
||||
settings_key?: string | null
|
||||
@@ -71,6 +72,10 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const field of getProviderFields(provider)) {
|
||||
const payloadKey = field.payload_key || field.settings_key || field.key
|
||||
if (field.input_type === 'checkbox') {
|
||||
payload[payloadKey] = values[field.key] === 'true'
|
||||
continue
|
||||
}
|
||||
const value = (values[field.key] || '').trim()
|
||||
if (field.secret && !value) continue
|
||||
payload[payloadKey] = value
|
||||
@@ -102,6 +107,18 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const fields = getProviderFields(provider)
|
||||
|
||||
// Seed checkbox defaults before the async settings load resolves
|
||||
const checkboxDefaults: Record<string, string> = {}
|
||||
for (const field of fields) {
|
||||
if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false'
|
||||
}
|
||||
if (Object.keys(checkboxDefaults).length > 0) {
|
||||
setProviderValues(prev => ({
|
||||
...prev,
|
||||
[provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) },
|
||||
}))
|
||||
}
|
||||
|
||||
if (cfg.settings_get) {
|
||||
apiClient.get(cfg.settings_get).then(res => {
|
||||
if (isCancelled) return
|
||||
@@ -112,7 +129,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
if (field.secret) continue
|
||||
const sourceKey = field.settings_key || field.payload_key || field.key
|
||||
const rawValue = (res.data as Record<string, unknown>)[sourceKey]
|
||||
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : ''
|
||||
if (rawValue != null) {
|
||||
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue)
|
||||
} else if (field.input_type === 'checkbox') {
|
||||
nextValues[field.key] = 'false'
|
||||
} else {
|
||||
nextValues[field.key] = ''
|
||||
}
|
||||
}
|
||||
setProviderValues(prev => ({
|
||||
...prev,
|
||||
@@ -198,14 +221,31 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
<div className="space-y-3">
|
||||
{fields.map(field => (
|
||||
<div key={`${provider.id}-${field.key}`}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||
<input
|
||||
type={field.input_type || 'text'}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
|
||||
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
{field.input_type === 'checkbox' ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values[field.key] === 'true'}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
|
||||
className="w-4 h-4 rounded border-slate-300 accent-slate-900"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||
<input
|
||||
type={field.input_type || 'text'}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
|
||||
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
{field.hint && (
|
||||
<p className="mt-1 text-xs text-slate-500">{t(`memories.${field.hint}`)}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -213,7 +253,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!canSave ? 'Save route is not configured for this provider' : isProviderSaveDisabled(provider) ? 'Please fill all required fields' : ''}
|
||||
title={!canSave ? t('memories.saveRouteNotConfigured') : isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''}
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
@@ -221,18 +261,23 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
onClick={() => handleTestProvider(provider)}
|
||||
disabled={!canTest || testing}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||
title={!canTest ? 'Test route is not configured for this provider' : ''}
|
||||
title={!canTest ? t('memories.testRouteNotConfigured') : ''}
|
||||
>
|
||||
{testing
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{connected && (
|
||||
{connected ? (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||
{t('memories.disconnected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
|
||||
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
|
||||
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : 'Error'))
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('common.error')))
|
||||
}
|
||||
|
||||
// Get category count (non-done items)
|
||||
@@ -479,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
due_date: dueDate || null, category: category || null,
|
||||
assigned_user_id: assignedUserId, priority,
|
||||
} as any)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -487,7 +487,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
try {
|
||||
await deleteTodoItem(tripId, item.id)
|
||||
onClose()
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
|
||||
@@ -663,7 +663,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
||||
assigned_user_id: assignedUserId,
|
||||
} as any)
|
||||
if (item?.id) onCreated(item.id)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
|
||||
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
||||
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
|
||||
const [memberSelectValue, setMemberSelectValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,8 +75,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (!trip) {
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
if (trip) {
|
||||
tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
|
||||
} else {
|
||||
setExistingMembers([])
|
||||
}
|
||||
}, [trip, isOpen])
|
||||
|
||||
@@ -365,12 +369,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members — only for new trips */}
|
||||
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||
{/* Members */}
|
||||
{allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<UserPlus className="inline w-4 h-4 mr-1" />{t('dashboard.addMembers')}
|
||||
<UserPlus className="inline w-4 h-4 mr-1" />{isEditing ? t('dashboard.addMembers') : t('dashboard.addMembers')}
|
||||
</label>
|
||||
{/* Existing members (editing mode) */}
|
||||
{isEditing && existingMembers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{existingMembers.map(m => (
|
||||
<span key={m.id}
|
||||
onClick={async () => {
|
||||
if (m.id === currentUser?.id) return
|
||||
try {
|
||||
await tripsApi.removeMember(trip!.id, m.id)
|
||||
setExistingMembers(prev => prev.filter(x => x.id !== m.id))
|
||||
toast.success(t('trips.memberRemoved', { username: m.username }))
|
||||
} catch { toast.error(t('trips.memberRemoveError')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
|
||||
background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)',
|
||||
cursor: m.id === currentUser?.id ? 'default' : 'pointer',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}>
|
||||
{m.username}
|
||||
{m.id !== currentUser?.id && <X size={11} style={{ color: 'var(--text-faint)' }} />}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Newly selected members (both modes) */}
|
||||
{selectedMembers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{selectedMembers.map(uid => {
|
||||
@@ -393,11 +423,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<CustomSelect
|
||||
value={memberSelectValue}
|
||||
onChange={value => {
|
||||
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') }
|
||||
onChange={async value => {
|
||||
if (!value) return
|
||||
if (isEditing && trip?.id) {
|
||||
const user = allUsers.find(u => u.id === Number(value))
|
||||
if (user) {
|
||||
try {
|
||||
await tripsApi.addMember(trip.id, user.username)
|
||||
setExistingMembers(prev => [...prev, { id: user.id, username: user.username }])
|
||||
toast.success(t('trips.memberAdded', { username: user.username }))
|
||||
} catch { toast.error(t('trips.memberAddError')) }
|
||||
}
|
||||
} else {
|
||||
setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)])
|
||||
}
|
||||
setMemberSelectValue('')
|
||||
}}
|
||||
placeholder={t('dashboard.addMember')}
|
||||
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))}
|
||||
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id) && !existingMembers.some(m => m.id === u.id)).map(u => ({ value: u.id, label: u.username }))}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -40,6 +40,11 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
|
||||
const toast = useToast()
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
shareApi.getLink(tripId).then(d => {
|
||||
@@ -77,7 +82,8 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
if (shareUrl) {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import { useMemo, useState, useCallback, useEffect } from 'react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import VacayMonthCard from './VacayMonthCard'
|
||||
import { Building2, MousePointer2 } from 'lucide-react'
|
||||
|
||||
@@ -9,6 +10,30 @@ export default function VacayCalendar() {
|
||||
const { t } = useTranslation()
|
||||
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
|
||||
const [companyMode, setCompanyMode] = useState(false)
|
||||
const [tripDates, setTripDates] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await tripsApi.list()
|
||||
const dates = new Set<string>()
|
||||
for (const trip of data.trips || []) {
|
||||
if (!trip.start_date || !trip.end_date) continue
|
||||
const start = new Date(trip.start_date + 'T00:00:00')
|
||||
const end = new Date(trip.end_date + 'T00:00:00')
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const y = d.getFullYear()
|
||||
if (y === selectedYear) {
|
||||
dates.add(`${y}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelled) setTripDates(dates)
|
||||
} catch { /* ignore */ }
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [selectedYear])
|
||||
|
||||
const companyHolidaySet = useMemo(() => {
|
||||
const s = new Set()
|
||||
@@ -45,7 +70,7 @@ export default function VacayCalendar() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<VacayMonthCard
|
||||
key={i}
|
||||
@@ -59,6 +84,8 @@ export default function VacayCalendar() {
|
||||
companyMode={companyMode}
|
||||
blockWeekends={blockWeekends}
|
||||
weekendDays={weekendDays}
|
||||
tripDates={tripDates}
|
||||
weekStart={plan?.week_start ?? 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -23,22 +23,26 @@ interface VacayMonthCardProps {
|
||||
companyMode: boolean
|
||||
blockWeekends: boolean
|
||||
weekendDays?: number[]
|
||||
tripDates?: Set<string>
|
||||
weekStart?: number
|
||||
}
|
||||
|
||||
export default function VacayMonthCard({
|
||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
|
||||
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1
|
||||
}: VacayMonthCardProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const weekdays = WEEKDAY_KEYS.map(k => t(k))
|
||||
const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const
|
||||
const orderedKeys = weekStart === 0 ? WEEKDAY_KEYS_SUNDAY : WEEKDAY_KEYS
|
||||
const weekdays = orderedKeys.map(k => t(k))
|
||||
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
|
||||
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
let startDow = firstDay.getDay() - 1
|
||||
if (startDow < 0) startDow = 6
|
||||
let startDow = firstDay.getDay() - weekStart
|
||||
if (startDow < 0) startDow += 7
|
||||
const cells = []
|
||||
for (let i = 0; i < startDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
@@ -57,11 +61,16 @@ export default function VacayMonthCard({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
{weekdays.map((wd, i) => (
|
||||
<div key={wd} className="text-center text-[10px] font-medium py-1" style={{ color: i >= 5 ? 'var(--text-faint)' : 'var(--text-muted)' }}>
|
||||
{wd}
|
||||
</div>
|
||||
))}
|
||||
{weekdays.map((wd, i) => {
|
||||
// Map column index back to JS day (0=Sun..6=Sat) to check if it's a weekend column
|
||||
const jsDay = (i + weekStart) % 7
|
||||
const isWeekendCol = weekendDays.includes(jsDay)
|
||||
return (
|
||||
<div key={`${wd}-${i}`} className="text-center text-[10px] font-medium py-1" style={{ color: isWeekendCol ? 'var(--text-faint)' : 'var(--text-muted)' }}>
|
||||
{wd}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -122,6 +131,10 @@ export default function VacayMonthCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tripDates?.has(dateStr) && (
|
||||
<span className="absolute top-[3px] right-[3px] w-[5px] h-[5px] rounded-full z-[2]" style={{ background: '#3b82f6' }} />
|
||||
)}
|
||||
|
||||
<span className="relative z-[1] text-[11px] font-medium" style={{
|
||||
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
fontWeight: dayEntries.length > 0 ? 700 : 500,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -75,17 +75,7 @@ describe('VacaySettings', () => {
|
||||
render(<VacaySettings onClose={vi.fn()} />)
|
||||
|
||||
// Day buttons should be visible (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
|
||||
// They have text from translation keys; in test env they fallback to keys or English
|
||||
// Check that 7 day-selector buttons exist (they are inside the paddingLeft:36 div)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// The day buttons are not toggle buttons (no inline-flex/rounded-full class)
|
||||
const dayButtons = allButtons.filter(b =>
|
||||
!b.className.includes('inline-flex') &&
|
||||
!b.className.includes('rounded-full') &&
|
||||
!b.className.includes('rounded-md') &&
|
||||
!b.className.includes('rounded-xl') &&
|
||||
!b.className.includes('rounded-lg')
|
||||
)
|
||||
const dayButtons = within(screen.getByTestId('weekend-days')).getAllByRole('button')
|
||||
// There should be 7 day buttons
|
||||
expect(dayButtons.length).toBe(7)
|
||||
})
|
||||
@@ -98,14 +88,8 @@ describe('VacaySettings', () => {
|
||||
})
|
||||
render(<VacaySettings onClose={vi.fn()} />)
|
||||
|
||||
// When block_weekends is false, the day selector section is not rendered
|
||||
// There should only be toggle buttons (4 toggles), no day buttons
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// None of the buttons should be day selectors (they have borderRadius:8 inline style)
|
||||
const dayButtons = allButtons.filter(b =>
|
||||
b.style.borderRadius === '8px' && b.style.padding === '4px 10px'
|
||||
)
|
||||
expect(dayButtons).toHaveLength(0)
|
||||
// When block_weekends is false, the weekend-days container is not rendered
|
||||
expect(screen.queryByTestId('weekend-days')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-VACAYSETTINGS-005: clicking an active weekend day removes it', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -51,7 +51,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
|
||||
{/* Weekend days selector */}
|
||||
{plan.block_weekends !== false && (
|
||||
<div style={{ paddingLeft: 36 }}>
|
||||
<div data-testid="weekend-days" style={{ paddingLeft: 36 }}>
|
||||
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
@@ -85,6 +85,37 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Week start */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<CalendarDays size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{t('vacay.weekStart')}</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('vacay.weekStartHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ paddingLeft: 36, marginTop: 8 }} className="flex gap-1.5">
|
||||
{[
|
||||
{ value: 1, label: t('vacay.mon') },
|
||||
{ value: 0, label: t('vacay.sun') },
|
||||
].map(({ value, label }) => {
|
||||
const active = (plan.week_start ?? 1) === value
|
||||
return (
|
||||
<button key={value} onClick={() => updatePlan({ week_start: value })}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
|
||||
background: active ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carry-over */}
|
||||
<SettingToggle
|
||||
icon={ArrowRightLeft}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function Modal({
|
||||
<div
|
||||
className={`
|
||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col max-h-[calc(100vh-90px)]
|
||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
||||
animate-in fade-in zoom-in-95 duration-200
|
||||
`}
|
||||
style={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
@@ -28,18 +28,27 @@ const ICON_COLORS: Record<ToastType, string> = {
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timersRef.current.forEach(clearTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
const id = ++toastIdCounter
|
||||
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
const t1 = setTimeout(() => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
const t2 = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 400)
|
||||
timersRef.current.push(t2)
|
||||
}, duration)
|
||||
timersRef.current.push(t1)
|
||||
}
|
||||
|
||||
return id
|
||||
@@ -47,9 +56,10 @@ export function ToastContainer() {
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
const t = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 400)
|
||||
timersRef.current.push(t)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user