Implemented complete mobile styling improvements for Scrapy UI: - Mobile-responsive sidebar with hamburger menu (Sheet component) - Sidebar hidden on mobile, slides in from left as overlay - Auto-closes on navigation - Mobile header with hamburger button, title, and theme toggle - Layout switches from horizontal to vertical flexbox on mobile - Reduced container padding on mobile (p-4 vs p-6) - All tables wrapped in horizontal scroll containers - Added whitespace-nowrap to prevent text wrapping in table cells - Optimized all dialogs for mobile: - Responsive width: max-w-[95vw] on mobile, max-w-[425px] on desktop - Full-width buttons on mobile - Proper gap spacing in footers - Text wrapping for long content (break-all for Job IDs) - Dashboard cards already responsive with grid breakpoints App now works flawlessly on mobile devices! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Header } from "@/components/header";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
FolderKanban,
|
|
Upload,
|
|
Trash2,
|
|
Package,
|
|
AlertCircle,
|
|
} from "lucide-react";
|
|
import { ListProjects, ListVersions } from "@/lib/types";
|
|
|
|
export default function ProjectsPage() {
|
|
const queryClient = useQueryClient();
|
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
|
|
// Fetch projects
|
|
const { data: projects, isLoading: isProjectsLoading } = useQuery({
|
|
queryKey: ["projects"],
|
|
queryFn: async (): Promise<ListProjects> => {
|
|
const res = await fetch("/ui/api/scrapyd/projects");
|
|
if (!res.ok) throw new Error("Failed to fetch projects");
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
// Fetch versions for selected project
|
|
const { data: versions } = useQuery({
|
|
queryKey: ["versions", selectedProject],
|
|
queryFn: async (): Promise<ListVersions> => {
|
|
const res = await fetch(
|
|
`/ui/api/scrapyd/versions?project=${selectedProject}`
|
|
);
|
|
if (!res.ok) throw new Error("Failed to fetch versions");
|
|
return res.json();
|
|
},
|
|
enabled: !!selectedProject,
|
|
});
|
|
|
|
// Delete project mutation
|
|
const deleteProjectMutation = useMutation({
|
|
mutationFn: async (project: string) => {
|
|
const res = await fetch("/ui/api/scrapyd/projects", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ project }),
|
|
});
|
|
if (!res.ok) throw new Error("Failed to delete project");
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
|
setDeleteDialogOpen(false);
|
|
setSelectedProject(null);
|
|
},
|
|
});
|
|
|
|
// Upload version mutation
|
|
const uploadVersionMutation = useMutation({
|
|
mutationFn: async (formData: FormData) => {
|
|
const res = await fetch("/ui/api/scrapyd/versions", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!res.ok) throw new Error("Failed to upload version");
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
|
queryClient.invalidateQueries({ queryKey: ["versions"] });
|
|
setUploadDialogOpen(false);
|
|
},
|
|
});
|
|
|
|
const handleUpload = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
uploadVersionMutation.mutate(formData);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Header
|
|
title="Projects"
|
|
description="Manage your Scrapyd projects and versions"
|
|
action={
|
|
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
Upload Project
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[425px]">
|
|
<form onSubmit={handleUpload}>
|
|
<DialogHeader>
|
|
<DialogTitle>Upload Project Version</DialogTitle>
|
|
<DialogDescription>
|
|
Upload a Python egg file for your Scrapy project
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="project">Project Name</Label>
|
|
<Input
|
|
id="project"
|
|
name="project"
|
|
placeholder="myproject"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="version">Version</Label>
|
|
<Input
|
|
id="version"
|
|
name="version"
|
|
placeholder="1.0.0"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="egg">Egg File</Label>
|
|
<Input
|
|
id="egg"
|
|
name="egg"
|
|
type="file"
|
|
accept=".egg"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
type="submit"
|
|
disabled={uploadVersionMutation.isPending}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
{uploadVersionMutation.isPending
|
|
? "Uploading..."
|
|
: "Upload"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
}
|
|
/>
|
|
|
|
{/* Projects List */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>All Projects</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isProjectsLoading ? (
|
|
<div className="space-y-2">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
))}
|
|
</div>
|
|
) : projects?.projects && projects.projects.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Project Name</TableHead>
|
|
<TableHead>Versions</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{projects.projects.map((project) => (
|
|
<TableRow
|
|
key={project}
|
|
onClick={() => setSelectedProject(project)}
|
|
className="cursor-pointer"
|
|
>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2 whitespace-nowrap">
|
|
<FolderKanban className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-medium">{project}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{selectedProject === project && versions ? (
|
|
<Badge variant="secondary" className="whitespace-nowrap">
|
|
{versions.versions.length} version(s)
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="whitespace-nowrap">Click to load</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Dialog
|
|
open={deleteDialogOpen && selectedProject === project}
|
|
onOpenChange={(open) => {
|
|
setDeleteDialogOpen(open);
|
|
if (open) setSelectedProject(project);
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedProject(project);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[425px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Project</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete "{project}"? This
|
|
action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeleteDialogOpen(false)}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() =>
|
|
deleteProjectMutation.mutate(project)
|
|
}
|
|
disabled={deleteProjectMutation.isPending}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
{deleteProjectMutation.isPending
|
|
? "Deleting..."
|
|
: "Delete"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<AlertCircle className="mb-4 h-12 w-12 text-muted-foreground" />
|
|
<h3 className="mb-2 text-lg font-semibold">No projects found</h3>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
Upload your first project to get started
|
|
</p>
|
|
<Button onClick={() => setUploadDialogOpen(true)}>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
Upload Project
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Project Versions */}
|
|
{selectedProject && versions && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
Versions for "{selectedProject}"
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{versions.versions.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{versions.versions.map((version) => (
|
|
<div
|
|
key={version}
|
|
className="flex items-center justify-between rounded-lg border p-3"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-mono text-sm">{version}</span>
|
|
</div>
|
|
<Badge variant="secondary">
|
|
{version === versions.versions[versions.versions.length - 1]
|
|
? "Latest"
|
|
: ""}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
No versions found for this project
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|