VS Code companion (#461)
* Init port discovery * Fmt * Remove unused * Fmt * Simplify * Container lookup API * Isolated task details * Fmt * Lint and format * Lint
This commit is contained in:
committed by
GitHub
parent
e970a6eb75
commit
141e1686fd
32
crates/db/.sqlx/query-233a016d4de730d203f4120f93daaddd10f3047ae17290c82dbbea1aafd064d1.json
generated
Normal file
32
crates/db/.sqlx/query-233a016d4de730d203f4120f93daaddd10f3047ae17290c82dbbea1aafd064d1.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT ta.id as \"attempt_id!: Uuid\",\n ta.task_id as \"task_id!: Uuid\",\n t.project_id as \"project_id!: Uuid\"\n FROM task_attempts ta\n JOIN tasks t ON ta.task_id = t.id\n WHERE ta.container_ref = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "attempt_id!: Uuid",
|
||||
"ordinal": 0,
|
||||
"type_info": "Blob"
|
||||
},
|
||||
{
|
||||
"name": "task_id!: Uuid",
|
||||
"ordinal": 1,
|
||||
"type_info": "Blob"
|
||||
},
|
||||
{
|
||||
"name": "project_id!: Uuid",
|
||||
"ordinal": 2,
|
||||
"type_info": "Blob"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "233a016d4de730d203f4120f93daaddd10f3047ae17290c82dbbea1aafd064d1"
|
||||
}
|
||||
@@ -556,6 +556,26 @@ impl TaskAttempt {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_container_ref(
|
||||
pool: &SqlitePool,
|
||||
container_ref: &str,
|
||||
) -> Result<(Uuid, Uuid, Uuid), sqlx::Error> {
|
||||
let result = sqlx::query!(
|
||||
r#"SELECT ta.id as "attempt_id!: Uuid",
|
||||
ta.task_id as "task_id!: Uuid",
|
||||
t.project_id as "project_id!: Uuid"
|
||||
FROM task_attempts ta
|
||||
JOIN tasks t ON ta.task_id = t.id
|
||||
WHERE ta.container_ref = ?"#,
|
||||
container_ref
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
Ok((result.attempt_id, result.task_id, result.project_id))
|
||||
}
|
||||
|
||||
pub async fn get_open_prs(pool: &SqlitePool) -> Result<Vec<PrInfo>, sqlx::Error> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT
|
||||
|
||||
@@ -5,7 +5,9 @@ use sqlx::Error as SqlxError;
|
||||
use strip_ansi_escapes::strip;
|
||||
use thiserror::Error;
|
||||
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||
use utils::{assets::asset_dir, browser::open_browser, sentry::sentry_layer};
|
||||
use utils::{
|
||||
assets::asset_dir, browser::open_browser, port_file::write_port_file, sentry::sentry_layer,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum VibeKanbanError {
|
||||
@@ -65,6 +67,9 @@ async fn main() -> Result<(), VibeKanbanError> {
|
||||
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?;
|
||||
let actual_port = listener.local_addr()?.port(); // get → 53427 (example)
|
||||
|
||||
// Write port file for discovery
|
||||
write_port_file(actual_port).await?;
|
||||
|
||||
tracing::info!("Server running on http://{host}:{actual_port}");
|
||||
|
||||
if !cfg!(debug_assertions) {
|
||||
|
||||
54
crates/server/src/routes/containers.rs
Normal file
54
crates/server/src/routes/containers.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
response::Json as ResponseJson,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use db::models::task_attempt::TaskAttempt;
|
||||
use deployment::Deployment;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use utils::response::ApiResponse;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, DeploymentImpl};
|
||||
|
||||
#[derive(Debug, Serialize, TS)]
|
||||
pub struct ContainerInfo {
|
||||
pub attempt_id: Uuid,
|
||||
pub task_id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ContainerQuery {
|
||||
#[serde(rename = "ref")]
|
||||
pub container_ref: String,
|
||||
}
|
||||
|
||||
pub async fn get_container_info(
|
||||
Query(query): Query<ContainerQuery>,
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
) -> Result<ResponseJson<ApiResponse<ContainerInfo>>, ApiError> {
|
||||
let pool = &deployment.db().pool;
|
||||
|
||||
let (attempt_id, task_id, project_id) =
|
||||
TaskAttempt::resolve_container_ref(pool, &query.container_ref)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => ApiError::Database(e),
|
||||
_ => ApiError::Database(e),
|
||||
})?;
|
||||
|
||||
let container_info = ContainerInfo {
|
||||
attempt_id,
|
||||
task_id,
|
||||
project_id,
|
||||
};
|
||||
|
||||
Ok(ResponseJson(ApiResponse::success(container_info)))
|
||||
}
|
||||
|
||||
pub fn router(_deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
||||
Router::new().route("/containers/info", get(get_container_info))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use crate::DeploymentImpl;
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod containers;
|
||||
pub mod filesystem;
|
||||
// pub mod github;
|
||||
pub mod events;
|
||||
@@ -23,6 +24,7 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService<Router> {
|
||||
let base_routes = Router::new()
|
||||
.route("/health", get(health::health_check))
|
||||
.merge(config::router())
|
||||
.merge(containers::router(&deployment))
|
||||
.merge(projects::router(&deployment))
|
||||
.merge(tasks::router(&deployment))
|
||||
.merge(task_attempts::router(&deployment))
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod diff;
|
||||
pub mod log_msg;
|
||||
pub mod msg_store;
|
||||
pub mod path;
|
||||
pub mod port_file;
|
||||
pub mod response;
|
||||
pub mod sentry;
|
||||
pub mod shell;
|
||||
|
||||
12
crates/utils/src/port_file.rs
Normal file
12
crates/utils/src/port_file.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
pub async fn write_port_file(port: u16) -> std::io::Result<PathBuf> {
|
||||
let dir = env::temp_dir().join("vibe-kanban");
|
||||
let path = dir.join("vibe-kanban.port");
|
||||
tracing::debug!("Writing port {} to {:?}", port, path);
|
||||
fs::create_dir_all(&dir).await?;
|
||||
fs::write(&path, port.to_string()).await?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { BrowserRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { Navbar } from '@/components/layout/navbar';
|
||||
import { Projects } from '@/pages/projects';
|
||||
import { ProjectTasks } from '@/pages/project-tasks';
|
||||
import { TaskDetailsPage } from '@/pages/task-details';
|
||||
|
||||
import { Settings } from '@/pages/Settings';
|
||||
import { McpServers } from '@/pages/McpServers';
|
||||
@@ -22,11 +23,12 @@ const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
|
||||
|
||||
function AppContent() {
|
||||
const { config, updateConfig, loading } = useConfig();
|
||||
const location = useLocation();
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [showPrivacyOptIn, setShowPrivacyOptIn] = useState(false);
|
||||
const [showGitHubLogin, setShowGitHubLogin] = useState(false);
|
||||
const showNavbar = true;
|
||||
const showNavbar = !location.pathname.endsWith('/full');
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
@@ -161,6 +163,14 @@ function AppContent() {
|
||||
path="/projects/:projectId/tasks"
|
||||
element={<ProjectTasks />}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId/tasks/:taskId/full"
|
||||
element={<TaskDetailsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId/tasks/:taskId/attempts/:attemptId/full"
|
||||
element={<TaskDetailsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId/tasks/:taskId/attempts/:attemptId"
|
||||
element={<ProjectTasks />}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button.tsx';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import TaskDetailsToolbar from '@/components/tasks/TaskDetailsToolbar.tsx';
|
||||
|
||||
function CollapsibleToolbar() {
|
||||
const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="px-4 pb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Task Details
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsHeaderCollapsed((prev) => !prev)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
{isHeaderCollapsed ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{!isHeaderCollapsed && <TaskDetailsToolbar />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CollapsibleToolbar);
|
||||
@@ -15,6 +15,7 @@ interface TaskDetailsHeaderProps {
|
||||
onClose: () => void;
|
||||
onEditTask?: (task: TaskWithAttemptStatus) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
@@ -46,6 +47,7 @@ function TaskDetailsHeader({
|
||||
onClose,
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
hideCloseButton = false,
|
||||
}: TaskDetailsHeaderProps) {
|
||||
const { task } = useContext(TaskDetailsContext);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
@@ -102,18 +104,20 @@ function TaskDetailsHeader({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Close panel</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{!hideCloseButton && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Close panel</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx';
|
||||
import ProcessesTab from '@/components/tasks/TaskDetails/ProcessesTab.tsx';
|
||||
import DeleteFileConfirmationDialog from '@/components/tasks/DeleteFileConfirmationDialog.tsx';
|
||||
import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx';
|
||||
import CollapsibleToolbar from '@/components/tasks/TaskDetails/CollapsibleToolbar.tsx';
|
||||
import TaskDetailsProvider from '../context/TaskDetailsContextProvider.tsx';
|
||||
import TaskDetailsToolbar from './TaskDetailsToolbar.tsx';
|
||||
|
||||
interface TaskDetailsPanelProps {
|
||||
task: TaskWithAttemptStatus | null;
|
||||
@@ -24,6 +24,9 @@ interface TaskDetailsPanelProps {
|
||||
onEditTask?: (task: TaskWithAttemptStatus) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
isDialogOpen?: boolean;
|
||||
hideBackdrop?: boolean;
|
||||
className?: string;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
export function TaskDetailsPanel({
|
||||
@@ -34,6 +37,9 @@ export function TaskDetailsPanel({
|
||||
onEditTask,
|
||||
onDeleteTask,
|
||||
isDialogOpen = false,
|
||||
hideBackdrop = false,
|
||||
className,
|
||||
hideHeader = false,
|
||||
}: TaskDetailsPanelProps) {
|
||||
const [showEditorDialog, setShowEditorDialog] = useState(false);
|
||||
|
||||
@@ -74,18 +80,23 @@ export function TaskDetailsPanel({
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
>
|
||||
{/* Backdrop - only on smaller screens (overlay mode) */}
|
||||
<div className={getBackdropClasses()} onClick={onClose} />
|
||||
{!hideBackdrop && (
|
||||
<div className={getBackdropClasses()} onClick={onClose} />
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div className={getTaskPanelClasses()}>
|
||||
<div className={className || getTaskPanelClasses()}>
|
||||
<div className="flex flex-col h-full">
|
||||
<TaskDetailsHeader
|
||||
onClose={onClose}
|
||||
onEditTask={onEditTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
/>
|
||||
{!hideHeader && (
|
||||
<TaskDetailsHeader
|
||||
onClose={onClose}
|
||||
onEditTask={onEditTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
hideCloseButton={hideBackdrop}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CollapsibleToolbar />
|
||||
<TaskDetailsToolbar />
|
||||
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -78,6 +78,7 @@ function TaskDetailsToolbar() {
|
||||
);
|
||||
|
||||
const { isStopping } = useContext(TaskAttemptStoppingContext);
|
||||
const location = useLocation();
|
||||
const { setAttemptData, isAttemptRunning } = useContext(
|
||||
TaskAttemptDataContext
|
||||
);
|
||||
@@ -91,7 +92,6 @@ function TaskDetailsToolbar() {
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { attemptId: urlAttemptId } = useParams<{ attemptId?: string }>();
|
||||
const { system, profiles } = useUserSystem();
|
||||
@@ -214,10 +214,11 @@ function TaskDetailsToolbar() {
|
||||
task &&
|
||||
(!urlAttemptId || urlAttemptId !== selectedAttemptToUse.id)
|
||||
) {
|
||||
navigate(
|
||||
`/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}`,
|
||||
{ replace: true }
|
||||
);
|
||||
const isFullScreen = location.pathname.endsWith('/full');
|
||||
const targetUrl = isFullScreen
|
||||
? `/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}/full`
|
||||
: `/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttemptToUse.id}`;
|
||||
navigate(targetUrl, { replace: true });
|
||||
}
|
||||
|
||||
return selectedAttemptToUse;
|
||||
@@ -259,13 +260,14 @@ function TaskDetailsToolbar() {
|
||||
(attempt: TaskAttempt | null) => {
|
||||
setSelectedAttempt(attempt);
|
||||
if (attempt && task) {
|
||||
navigate(
|
||||
`/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}`,
|
||||
{ replace: true }
|
||||
);
|
||||
const isFullScreen = location.pathname.endsWith('/full');
|
||||
const targetUrl = isFullScreen
|
||||
? `/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}/full`
|
||||
: `/projects/${projectId}/tasks/${task.id}/attempts/${attempt.id}`;
|
||||
navigate(targetUrl, { replace: true });
|
||||
}
|
||||
},
|
||||
[navigate, projectId, task, setSelectedAttempt]
|
||||
[navigate, projectId, task, setSelectedAttempt, location.pathname]
|
||||
);
|
||||
|
||||
// Stub handlers for backward compatibility with CreateAttempt
|
||||
@@ -323,7 +325,7 @@ function TaskDetailsToolbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 pb-4 border-b">
|
||||
<div className="p-4 border-b">
|
||||
{/* Error Display */}
|
||||
{ui.error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
|
||||
131
frontend/src/pages/task-details.tsx
Normal file
131
frontend/src/pages/task-details.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
|
||||
import { projectsApi, tasksApi } from '@/lib/api';
|
||||
import type { TaskWithAttemptStatus, Project } from 'shared/types';
|
||||
|
||||
export function TaskDetailsPage() {
|
||||
const { projectId, taskId, attemptId } = useParams<{
|
||||
projectId: string;
|
||||
taskId: string;
|
||||
attemptId?: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [task, setTask] = useState<TaskWithAttemptStatus | null>(null);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
navigate(`/projects/${projectId}/tasks`, { replace: true });
|
||||
};
|
||||
|
||||
const handleEditTask = (task: TaskWithAttemptStatus) => {
|
||||
// Navigate back to main task page and trigger edit
|
||||
navigate(`/projects/${projectId}/tasks/${task.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteTask = () => {
|
||||
// Navigate back to main task page after deletion
|
||||
// navigate(`/projects/${projectId}/tasks`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!projectId || !taskId) {
|
||||
setError('Missing project or task ID');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch both project and tasks in parallel
|
||||
const [projectResult, tasksResult] = await Promise.all([
|
||||
projectsApi.getById(projectId),
|
||||
tasksApi.getAll(projectId),
|
||||
]);
|
||||
|
||||
// Find the specific task from the list (to get TaskWithAttemptStatus)
|
||||
const foundTask = tasksResult.find((t) => t.id === taskId);
|
||||
|
||||
if (!foundTask) {
|
||||
setError('Task not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProject(projectResult);
|
||||
setTask(foundTask);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch task details:', err);
|
||||
setError('Failed to load task details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [projectId, taskId, attemptId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Loader message="Loading task details..." size={32} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-destructive text-lg mb-4">{error}</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to tasks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task || !project) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground text-lg mb-4">
|
||||
Task not found
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to tasks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TaskDetailsPanel
|
||||
task={task}
|
||||
projectHasDevScript={!!project.dev_script}
|
||||
projectId={projectId!}
|
||||
onClose={handleClose}
|
||||
onEditTask={handleEditTask}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
hideBackdrop={true}
|
||||
hideHeader={true}
|
||||
className="w-full h-screen flex flex-col"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user