feat(project): enhance channel and issues pages
Update ChannelPage with message list integration, enhance IssuesPage with drag-and-drop support, add NewIssuePage.
This commit is contained in:
parent
f4653f2399
commit
c308fc044d
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle, MessageSquare, Pin, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useWsConnected,
|
useWsConnected,
|
||||||
getWsClient,
|
getWsClient,
|
||||||
@ -8,17 +8,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
useRoom,
|
useRoom,
|
||||||
} from '@/contexts/room';
|
} from '@/contexts/room';
|
||||||
import { useProjectLayout } from '@/app/project/layout';
|
import { ProjectJoinBanner, useProjectLayout } from '@/app/project/layout';
|
||||||
import type { Message, ReactionGroup, Member, ThreadState } from '@/contexts/room';
|
import type { Message, ReactionGroup, Member, ThreadState } from '@/contexts/room';
|
||||||
import {
|
import {
|
||||||
ThreadPanel,
|
ThreadPanel,
|
||||||
|
PinPanel,
|
||||||
EditHistoryOverlay,
|
EditHistoryOverlay,
|
||||||
MessageList,
|
MessageList,
|
||||||
MessageInput,
|
MessageInput,
|
||||||
} from '@/components/channel';
|
} from '@/components/channel';
|
||||||
import { MentionBottomSheet } from '@/components/channel/mention';
|
import { MentionBottomSheet } from '@/components/channel/mention';
|
||||||
import type { MentionSelection, MentionEntityType } from '@/components/channel/mention/types';
|
import type { MentionSelection, MentionEntityType } from '@/components/channel/mention/types';
|
||||||
import { projectRepos, aiList, skillList } from '@/client/api';
|
import { projectRepos, aiList, skillList, threadCreate, threadMessages } from '@/client/api';
|
||||||
|
|
||||||
function safeGetClient() {
|
function safeGetClient() {
|
||||||
try { return getWsClient(); } catch { return null; }
|
try { return getWsClient(); } catch { return null; }
|
||||||
@ -31,6 +32,8 @@ function ChannelPageInner() {
|
|||||||
wsStatus,
|
wsStatus,
|
||||||
currentRoom,
|
currentRoom,
|
||||||
members,
|
members,
|
||||||
|
pinnedMessages,
|
||||||
|
threads,
|
||||||
messages,
|
messages,
|
||||||
isHistoryLoaded,
|
isHistoryLoaded,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
@ -38,11 +41,13 @@ function ChannelPageInner() {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
editMessage,
|
editMessage,
|
||||||
revokeMessage,
|
revokeMessage,
|
||||||
|
removePin,
|
||||||
|
setThreads,
|
||||||
typingUsers,
|
typingUsers,
|
||||||
} = useRoom();
|
} = useRoom();
|
||||||
|
|
||||||
const isConnected = useWsConnected();
|
const isConnected = useWsConnected();
|
||||||
const { setCurrentRoomName } = useProjectLayout();
|
const { isProjectPreview, setCurrentRoomName } = useProjectLayout();
|
||||||
|
|
||||||
// Sync room name to layout Header
|
// Sync room name to layout Header
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -55,6 +60,7 @@ function ChannelPageInner() {
|
|||||||
const [replyToMessageId, setReplyToMessageId] = useState<string | null>(null);
|
const [replyToMessageId, setReplyToMessageId] = useState<string | null>(null);
|
||||||
const [emojiPickerMessageId, setEmojiPickerMessageId] = useState<string | null>(null);
|
const [emojiPickerMessageId, setEmojiPickerMessageId] = useState<string | null>(null);
|
||||||
const [activeThread, setActiveThread] = useState<ThreadState | null>(null);
|
const [activeThread, setActiveThread] = useState<ThreadState | null>(null);
|
||||||
|
const [sidePanel, setSidePanel] = useState<'pins' | 'threads' | null>(null);
|
||||||
const [editHistoryMessageId, setEditHistoryMessageId] = useState<string | null>(null);
|
const [editHistoryMessageId, setEditHistoryMessageId] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
@ -75,7 +81,7 @@ function ChannelPageInner() {
|
|||||||
|
|
||||||
// Fetch AI agents, repos, and skills in parallel when room opens
|
// Fetch AI agents, repos, and skills in parallel when room opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomIdParam) return;
|
if (!roomIdParam || isProjectPreview) return;
|
||||||
const projectName = window.location.pathname.split('/')[1];
|
const projectName = window.location.pathname.split('/')[1];
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@ -100,7 +106,7 @@ function ChannelPageInner() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [roomIdParam]);
|
}, [isProjectPreview, roomIdParam]);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@ -113,10 +119,10 @@ function ChannelPageInner() {
|
|||||||
|
|
||||||
// Sync room name to layout Header
|
// Sync room name to layout Header
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wsStatus === 'connected' && isConnected) {
|
if (!isProjectPreview && wsStatus === 'connected' && isConnected) {
|
||||||
loadHistory();
|
loadHistory();
|
||||||
}
|
}
|
||||||
}, [wsStatus, isConnected, loadHistory]);
|
}, [isProjectPreview, wsStatus, isConnected, loadHistory]);
|
||||||
|
|
||||||
// Load older messages when scrolling to top
|
// Load older messages when scrolling to top
|
||||||
const handleStartReached = useCallback(() => {
|
const handleStartReached = useCallback(() => {
|
||||||
@ -137,6 +143,7 @@ function ChannelPageInner() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (isProjectPreview) return;
|
||||||
const currentValue = e.target.value;
|
const currentValue = e.target.value;
|
||||||
setInputValue(currentValue);
|
setInputValue(currentValue);
|
||||||
|
|
||||||
@ -200,7 +207,7 @@ function ChannelPageInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = () => {
|
const handleSendMessage = () => {
|
||||||
if (!inputValue.trim()) return;
|
if (isProjectPreview || !inputValue.trim()) return;
|
||||||
const content = resolveContent(inputValue);
|
const content = resolveContent(inputValue);
|
||||||
if (editingMessageId) {
|
if (editingMessageId) {
|
||||||
editMessage(editingMessageId, content);
|
editMessage(editingMessageId, content);
|
||||||
@ -346,35 +353,98 @@ function ChannelPageInner() {
|
|||||||
|
|
||||||
// ── Thread ──
|
// ── Thread ──
|
||||||
|
|
||||||
const openThread = useCallback(
|
const normalizeThreadMessages = useCallback((parent: Message | null, threadMsgs: Message[]) => {
|
||||||
async (msg: Message) => {
|
const map = new Map<string, Message>();
|
||||||
if (!roomIdParam || !msg.thread) return;
|
if (parent) map.set(parent.id, parent);
|
||||||
|
for (const msg of threadMsgs) map.set(msg.id, msg);
|
||||||
|
return [...map.values()].sort((a, b) => a.seq - b.seq);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openThreadByState = useCallback(
|
||||||
|
async (thread: ThreadState, parentMessage?: Message | null) => {
|
||||||
|
if (isProjectPreview || !roomIdParam) return;
|
||||||
try {
|
try {
|
||||||
const { threadMessages } = await import('@/client/api');
|
const res = await threadMessages(roomIdParam, thread.id, { limit: 100 });
|
||||||
const res = await threadMessages(roomIdParam, msg.thread);
|
const threadMsgs: Message[] = (res.data?.data?.messages ?? []).map((r) => ({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
...r,
|
||||||
const threadMsgs: Message[] = (res.data?.data?.messages ?? []).map((r: any) => ({
|
_localReactions: [],
|
||||||
...r, _localReactions: [], is_streaming: false, isOptimistic: false, isOptimisticError: false, thinking_content: null,
|
is_streaming: false,
|
||||||
|
isOptimistic: false,
|
||||||
|
isOptimisticError: false,
|
||||||
|
thinking_content: null,
|
||||||
}));
|
}));
|
||||||
|
const parent = parentMessage ?? messages.find((m) => m.seq === thread.parent) ?? null;
|
||||||
setActiveThread({
|
setActiveThread({
|
||||||
id: msg.thread, parent: msg.seq, created_by: '', participants: [],
|
...thread,
|
||||||
last_message_at: new Date().toISOString(), last_message_preview: null,
|
messages: normalizeThreadMessages(parent, threadMsgs),
|
||||||
created_at: '', messages: threadMsgs, isOpen: true,
|
isOpen: true,
|
||||||
});
|
});
|
||||||
|
setSidePanel(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ChannelPage] failed to open thread:', err);
|
console.error('[ChannelPage] failed to open thread:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[roomIdParam],
|
[isProjectPreview, messages, normalizeThreadMessages, roomIdParam],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openThread = useCallback(
|
||||||
|
async (msg: Message) => {
|
||||||
|
if (isProjectPreview || !roomIdParam) return;
|
||||||
|
const existing = threads.find((t) => t.id === msg.thread || t.parent === msg.seq);
|
||||||
|
if (existing) {
|
||||||
|
await openThreadByState(existing, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await threadCreate(roomIdParam, { parent_seq: msg.seq });
|
||||||
|
const data = res.data?.data;
|
||||||
|
if (!data) return;
|
||||||
|
const nextThread: ThreadState = {
|
||||||
|
id: data.id,
|
||||||
|
parent: data.parent,
|
||||||
|
created_by: data.created_by,
|
||||||
|
participants: data.participants,
|
||||||
|
last_message_at: data.last_message_at,
|
||||||
|
last_message_preview: data.last_message_preview ?? null,
|
||||||
|
created_at: data.created_at,
|
||||||
|
messages: [msg],
|
||||||
|
isOpen: true,
|
||||||
|
};
|
||||||
|
setThreads((prev) => (prev.some((t) => t.id === nextThread.id) ? prev : [...prev, nextThread]));
|
||||||
|
setActiveThread(nextThread);
|
||||||
|
setSidePanel(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ChannelPage] failed to create thread:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isProjectPreview, openThreadByState, roomIdParam, setThreads, threads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayedThread = useMemo(() => {
|
||||||
|
if (!activeThread) return null;
|
||||||
|
const parent = messages.find((m) => m.seq === activeThread.parent) ?? null;
|
||||||
|
const liveThreadMessages = messages.filter((m) => m.thread === activeThread.id);
|
||||||
|
const merged = normalizeThreadMessages(parent, [...activeThread.messages, ...liveThreadMessages]);
|
||||||
|
return { ...activeThread, messages: merged };
|
||||||
|
}, [activeThread, messages, normalizeThreadMessages]);
|
||||||
|
|
||||||
const closeThread = () => setActiveThread(null);
|
const closeThread = () => setActiveThread(null);
|
||||||
|
|
||||||
|
const gotoMessage = useCallback((messageId: string) => {
|
||||||
|
setSidePanel(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const escape = window.CSS?.escape ?? ((value: string) => value.replace(/"/g, '\\"'));
|
||||||
|
const el = document.querySelector(`[data-message-id="${escape(messageId)}"]`);
|
||||||
|
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── File upload ──
|
// ── File upload ──
|
||||||
|
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0 || !roomIdParam) return;
|
if (isProjectPreview || !files || files.length === 0 || !roomIdParam) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -424,6 +494,45 @@ function ChannelPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: '8px 16px', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveThread(null); setSidePanel((prev) => (prev === 'pins' ? null : 'pins')); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '5px 10px',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: sidePanel === 'pins' ? 'var(--surface-elevated)' : 'transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pin className="w-3 h-3" />
|
||||||
|
Pins {pinnedMessages.length}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveThread(null); setSidePanel((prev) => (prev === 'threads' ? null : 'threads')); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '5px 10px',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: sidePanel === 'threads' ? 'var(--surface-elevated)' : 'transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
Threads {threads.length}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MessageList
|
<MessageList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isLoadingHistory={isLoadingMore}
|
isLoadingHistory={isLoadingMore}
|
||||||
@ -438,6 +547,7 @@ function ChannelPageInner() {
|
|||||||
onShowEditHistory={setEditHistoryMessageId}
|
onShowEditHistory={setEditHistoryMessageId}
|
||||||
onStartReached={handleStartReached}
|
onStartReached={handleStartReached}
|
||||||
roomId={roomIdParam}
|
roomId={roomIdParam}
|
||||||
|
readOnly={isProjectPreview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{mentionOpen && (
|
{mentionOpen && (
|
||||||
@ -463,36 +573,98 @@ function ChannelPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MessageInput
|
{isProjectPreview ? (
|
||||||
value={inputValue}
|
<div className="shrink-0 p-4">
|
||||||
roomName={currentRoom?.room_name ?? roomIdParam}
|
<ProjectJoinBanner compact message="Join this project to send messages in channels." />
|
||||||
isConnected={isConnected}
|
</div>
|
||||||
isEditing={!!editingMessageId}
|
) : (
|
||||||
replyToMessageId={replyToMessageId}
|
<MessageInput
|
||||||
uploading={uploading}
|
value={inputValue}
|
||||||
inputRef={inputRef}
|
roomName={currentRoom?.room_name ?? roomIdParam}
|
||||||
fileInputRef={fileInputRef}
|
isConnected={isConnected}
|
||||||
onChange={handleInputChange}
|
isEditing={!!editingMessageId}
|
||||||
onKeyDown={handleKeyDown}
|
replyToMessageId={replyToMessageId}
|
||||||
onSend={handleSendMessage}
|
uploading={uploading}
|
||||||
onFileSelect={handleFileSelect}
|
inputRef={inputRef}
|
||||||
onCancelReply={() => setReplyToMessageId(null)}
|
fileInputRef={fileInputRef}
|
||||||
onCancelEdit={() => { setEditingMessageId(null); setInputValue(''); pendingMentionsRef.current.clear(); }}
|
onChange={handleInputChange}
|
||||||
onMention={handleOpenMention}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
onSend={handleSendMessage}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onCancelReply={() => setReplyToMessageId(null)}
|
||||||
|
onCancelEdit={() => { setEditingMessageId(null); setInputValue(''); pendingMentionsRef.current.clear(); }}
|
||||||
|
onMention={handleOpenMention}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeThread && (
|
{activeThread && (
|
||||||
<ThreadPanel
|
<ThreadPanel
|
||||||
thread={activeThread}
|
thread={displayedThread ?? activeThread}
|
||||||
typingUsers={typingUsersList}
|
typingUsers={typingUsersList}
|
||||||
onClose={closeThread}
|
onClose={closeThread}
|
||||||
sendMessage={(content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => sendMessage(content, opts)}
|
sendMessage={(content: string, opts?: { contentType?: string; thread?: string; inReplyTo?: string; attachmentIds?: string[] }) => sendMessage(content, opts)}
|
||||||
onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }}
|
onTypingStart={() => { const c = safeGetClient(); if (c) c.sendTypingStart(roomIdParam); }}
|
||||||
onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }}
|
onTypingStop={() => { const c = safeGetClient(); if (c) c.sendTypingStop(roomIdParam); }}
|
||||||
|
readOnly={isProjectPreview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!activeThread && sidePanel === 'pins' && (
|
||||||
|
<PinPanel
|
||||||
|
pins={pinnedMessages}
|
||||||
|
messages={messages}
|
||||||
|
onClose={() => setSidePanel(null)}
|
||||||
|
onGotoMessage={gotoMessage}
|
||||||
|
onUnpin={isProjectPreview ? undefined : (messageId) => {
|
||||||
|
removePin(messageId).catch((err) => console.error('[ChannelPage] failed to unpin message:', err));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!activeThread && sidePanel === 'threads' && (
|
||||||
|
<div className="thread-panel">
|
||||||
|
<div className="thread-panel-header">
|
||||||
|
<div className="thread-panel-title">
|
||||||
|
<MessageSquare className="w-4 h-4" style={{ color: 'var(--text-primary)' }} />
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>Threads</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSidePanel(null)} className="thread-close-btn" title="Close Threads">
|
||||||
|
<X className="w-4 h-4" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="thread-messages">
|
||||||
|
{threads.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: 12 }}>
|
||||||
|
No threads yet. Use the message action menu to start one.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
threads.map((thread) => {
|
||||||
|
const parent = messages.find((msg) => msg.seq === thread.parent) ?? null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={thread.id}
|
||||||
|
onClick={() => openThreadByState(thread, parent)}
|
||||||
|
className="thread-list-item"
|
||||||
|
disabled={isProjectPreview}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{parent?.display_name ?? `Message #${thread.parent}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: 12, lineHeight: 1.4 }}>
|
||||||
|
{thread.last_message_preview ?? parent?.content ?? 'Thread has no replies yet.'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
|
||||||
|
Updated {new Date(thread.last_message_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editHistoryMessageId && (
|
{editHistoryMessageId && (
|
||||||
<EditHistoryOverlay
|
<EditHistoryOverlay
|
||||||
messageId={editHistoryMessageId}
|
messageId={editHistoryMessageId}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { memo, useState, useMemo, useDeferredValue, useRef, useCallback } from "
|
|||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { stripMarkdown, truncate } from "@/lib/utils";
|
import { stripMarkdown, truncate } from "@/lib/utils";
|
||||||
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
|
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
|
||||||
|
import { useProjectLayout } from "@/app/project/layout";
|
||||||
|
|
||||||
interface IssueRowProps {
|
interface IssueRowProps {
|
||||||
issue: IssueResponse;
|
issue: IssueResponse;
|
||||||
@ -108,6 +109,7 @@ const OVERSCAN = 5;
|
|||||||
export function IssuesPage() {
|
export function IssuesPage() {
|
||||||
const { projectName } = useParams<{ projectName: string }>();
|
const { projectName } = useParams<{ projectName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isProjectPreview } = useProjectLayout();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
|
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@ -199,13 +201,15 @@ export function IssuesPage() {
|
|||||||
<h1 className={ISSUES_PAGE.pageTitle}>Issues</h1>
|
<h1 className={ISSUES_PAGE.pageTitle}>Issues</h1>
|
||||||
<p className={ISSUES_PAGE.pageSub}>Track and manage project tasks and bugs</p>
|
<p className={ISSUES_PAGE.pageSub}>Track and manage project tasks and bugs</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{!isProjectPreview && (
|
||||||
onClick={() => navigate(`/${projectName}/issues/new`)}
|
<button
|
||||||
className={ISSUES_PAGE.newBtn}
|
onClick={() => navigate(`/${projectName}/issues/new`)}
|
||||||
>
|
className={ISSUES_PAGE.newBtn}
|
||||||
<Plus className="w-4 h-4" />
|
>
|
||||||
New issue
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
New issue
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@ -268,7 +272,7 @@ export function IssuesPage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1 mb-6 text-center max-w-[300px]">
|
<p className="text-sm text-muted-foreground mt-1 mb-6 text-center max-w-[300px]">
|
||||||
{searchQuery ? "Try adjusting your search or filters to find what you're looking for." : "You're all caught up! Create an issue to track new tasks."}
|
{searchQuery ? "Try adjusting your search or filters to find what you're looking for." : "You're all caught up! Create an issue to track new tasks."}
|
||||||
</p>
|
</p>
|
||||||
{!searchQuery && activeTab === 'open' && (
|
{!isProjectPreview && !searchQuery && activeTab === 'open' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/${projectName}/issues/new`)}
|
onClick={() => navigate(`/${projectName}/issues/new`)}
|
||||||
className={ISSUES_PAGE.newBtn}
|
className={ISSUES_PAGE.newBtn}
|
||||||
|
|||||||
@ -11,10 +11,12 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Info
|
Info
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ProjectJoinBanner, useProjectLayout } from "@/app/project/layout";
|
||||||
|
|
||||||
export function NewIssuePage() {
|
export function NewIssuePage() {
|
||||||
const { projectName } = useParams<{ projectName: string }>();
|
const { projectName } = useParams<{ projectName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isProjectPreview } = useProjectLayout();
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
@ -22,6 +24,16 @@ export function NewIssuePage() {
|
|||||||
|
|
||||||
const createMutation = useCreateIssueMutation(projectName);
|
const createMutation = useCreateIssueMutation(projectName);
|
||||||
|
|
||||||
|
if (isProjectPreview) {
|
||||||
|
return (
|
||||||
|
<div className={ISSUES_PAGE.container}>
|
||||||
|
<div className="max-w-[800px] w-full mx-auto mt-8">
|
||||||
|
<ProjectJoinBanner message="Join this project before creating issues." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user