Enable management of media outlet articles including creation, editing, and deletion

Adds article management functionality to media outlets, including API endpoints for creating, updating, and deleting articles, and integrates these features into the client-side UI with dialogs and mutations.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9a264234-c5d7-4dcc-adf3-a954b149b30d
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/9a264234-c5d7-4dcc-adf3-a954b149b30d/QTw0kIA
This commit is contained in:
kimjaehyeon0101
2025-10-14 10:01:14 +00:00
parent 4f0b4c7e1d
commit 3829c9bc87
4 changed files with 370 additions and 15 deletions

View File

@ -18,6 +18,10 @@ externalPort = 80
localPort = 34047
externalPort = 3002
[[ports]]
localPort = 34595
externalPort = 6800
[[ports]]
localPort = 36309
externalPort = 5173

View File

@ -1,11 +1,15 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft, Edit, Plus, BarChart3, Gavel, MessageSquare } from "lucide-react";
import type { MediaOutlet } from "@shared/schema";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ArrowLeft, Edit, Plus, BarChart3, Gavel, MessageSquare, Trash2, Eye } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { queryClient, apiRequest } from "@/lib/queryClient";
import type { MediaOutlet, Article } from "@shared/schema";
interface MediaOutletManagementProps {
outlet: MediaOutlet;
@ -14,12 +18,127 @@ interface MediaOutletManagementProps {
export default function MediaOutletManagement({ outlet, onBack }: MediaOutletManagementProps) {
const [activeTab, setActiveTab] = useState("overview");
// TODO: Add queries for articles, predictions, auctions, comments related to this outlet
const { data: articles = [] } = useQuery({
queryKey: ["/api/articles", outlet.id],
enabled: false, // Disabled for now as articles API is not implemented
const [isArticleDialogOpen, setIsArticleDialogOpen] = useState(false);
const [editingArticle, setEditingArticle] = useState<Article | null>(null);
const [articleForm, setArticleForm] = useState({
title: "",
slug: "",
content: "",
excerpt: "",
imageUrl: "",
publishedMinutesAgo: 0,
isFeatured: false
});
const { toast } = useToast();
const { data: articles = [], isLoading: articlesLoading } = useQuery<Article[]>({
queryKey: [`/api/media-outlets/${outlet.slug}/articles`],
});
const createArticleMutation = useMutation({
mutationFn: async (data: typeof articleForm) => {
return await apiRequest("/api/articles", "POST", { ...data, mediaOutletId: outlet.id });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/media-outlets/${outlet.slug}/articles`] });
setIsArticleDialogOpen(false);
resetArticleForm();
toast({
title: "Article Created",
description: "The article has been created successfully.",
});
},
onError: (error: Error) => {
toast({
title: "Error",
description: error.message || "Failed to create article",
variant: "destructive",
});
}
});
const updateArticleMutation = useMutation({
mutationFn: async (data: { id: string; updates: Partial<typeof articleForm> }) => {
return await apiRequest(`/api/articles/${data.id}`, "PATCH", data.updates);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/media-outlets/${outlet.slug}/articles`] });
setIsArticleDialogOpen(false);
setEditingArticle(null);
resetArticleForm();
toast({
title: "Article Updated",
description: "The article has been updated successfully.",
});
},
onError: (error: Error) => {
toast({
title: "Error",
description: error.message || "Failed to update article",
variant: "destructive",
});
}
});
const deleteArticleMutation = useMutation({
mutationFn: async (id: string) => {
return await apiRequest(`/api/articles/${id}`, "DELETE", {});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/media-outlets/${outlet.slug}/articles`] });
toast({
title: "Article Deleted",
description: "The article has been deleted successfully.",
});
},
onError: (error: Error) => {
toast({
title: "Error",
description: error.message || "Failed to delete article",
variant: "destructive",
});
}
});
const resetArticleForm = () => {
setArticleForm({
title: "",
slug: "",
content: "",
excerpt: "",
imageUrl: "",
publishedMinutesAgo: 0,
isFeatured: false
});
};
const openNewArticleDialog = () => {
resetArticleForm();
setEditingArticle(null);
setIsArticleDialogOpen(true);
};
const openEditArticleDialog = (article: Article) => {
setArticleForm({
title: article.title,
slug: article.slug,
content: article.content || "",
excerpt: article.excerpt || "",
imageUrl: article.imageUrl || "",
publishedMinutesAgo: article.publishedMinutesAgo,
isFeatured: article.isFeatured || false
});
setEditingArticle(article);
setIsArticleDialogOpen(true);
};
const handleSubmitArticle = () => {
if (editingArticle) {
updateArticleMutation.mutate({ id: editingArticle.id, updates: articleForm });
} else {
createArticleMutation.mutate(articleForm);
}
};
return (
<div className="min-h-screen bg-gray-50">
@ -84,7 +203,7 @@ export default function MediaOutletManagement({ outlet, onBack }: MediaOutletMan
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-500">Total Articles</h3>
<p className="text-2xl font-bold">0</p>
<p className="text-2xl font-bold">{articles.length}</p>
</div>
<div className="text-blue-600">
<BarChart3 className="h-6 w-6" />
@ -207,18 +326,90 @@ export default function MediaOutletManagement({ outlet, onBack }: MediaOutletMan
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Article Management</CardTitle>
<Button data-testid="button-new-article">
<CardTitle>Article Management ({articles.length})</CardTitle>
<Button onClick={openNewArticleDialog} data-testid="button-new-article">
<Plus className="h-4 w-4 mr-2" />
Create New Article
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<div className="text-gray-400 text-lg mb-2">No articles yet</div>
<div className="text-gray-500 text-sm">Create your first article</div>
</div>
{articlesLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center space-x-4 p-4 border rounded-lg">
<div className="flex-1">
<div className="h-4 bg-gray-200 rounded mb-2 w-3/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
<div className="flex space-x-2">
<div className="h-8 w-16 bg-gray-200 rounded"></div>
<div className="h-8 w-16 bg-gray-200 rounded"></div>
</div>
</div>
))}
</div>
) : articles.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-lg mb-2">No articles yet</div>
<div className="text-gray-500 text-sm">Create your first article</div>
</div>
) : (
<div className="space-y-3">
{articles.map((article) => (
<div
key={article.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition"
data-testid={`article-item-${article.id}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<h3 className="font-medium text-gray-900 truncate">{article.title}</h3>
{article.isFeatured && (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">
Featured
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">{article.excerpt || "No excerpt"}</p>
<div className="text-xs text-gray-400 mt-1">
Published {article.publishedMinutesAgo} minutes ago Slug: {article.slug}
</div>
</div>
<div className="flex items-center space-x-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => window.open(`/articles/${article.slug}`, '_blank')}
data-testid={`button-view-${article.id}`}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openEditArticleDialog(article)}
data-testid={`button-edit-${article.id}`}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm('Are you sure you want to delete this article?')) {
deleteArticleMutation.mutate(article.id);
}
}}
data-testid={`button-delete-${article.id}`}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
@ -278,6 +469,112 @@ export default function MediaOutletManagement({ outlet, onBack }: MediaOutletMan
</TabsContent>
</Tabs>
</div>
{/* Article Dialog */}
<Dialog open={isArticleDialogOpen} onOpenChange={setIsArticleDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingArticle ? "Edit Article" : "Create New Article"}</DialogTitle>
<DialogDescription>
{editingArticle ? "Update the article details below." : "Fill in the details to create a new article."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div>
<label className="text-sm font-medium text-gray-700">Title</label>
<Input
value={articleForm.title}
onChange={(e) => setArticleForm({ ...articleForm, title: e.target.value })}
placeholder="Enter article title"
data-testid="input-article-title"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Slug</label>
<Input
value={articleForm.slug}
onChange={(e) => setArticleForm({ ...articleForm, slug: e.target.value })}
placeholder="article-slug-url"
data-testid="input-article-slug"
/>
<p className="text-xs text-gray-500 mt-1">URL-friendly identifier (e.g., my-article-title)</p>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Excerpt</label>
<Textarea
value={articleForm.excerpt}
onChange={(e) => setArticleForm({ ...articleForm, excerpt: e.target.value })}
placeholder="Brief summary of the article"
rows={2}
data-testid="input-article-excerpt"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Content</label>
<Textarea
value={articleForm.content}
onChange={(e) => setArticleForm({ ...articleForm, content: e.target.value })}
placeholder="Article content (supports markdown)"
rows={8}
data-testid="input-article-content"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Image URL</label>
<Input
value={articleForm.imageUrl}
onChange={(e) => setArticleForm({ ...articleForm, imageUrl: e.target.value })}
placeholder="https://example.com/image.jpg"
data-testid="input-article-image"
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700">Published Minutes Ago</label>
<Input
type="number"
value={articleForm.publishedMinutesAgo}
onChange={(e) => setArticleForm({ ...articleForm, publishedMinutesAgo: parseInt(e.target.value) || 0 })}
placeholder="0"
data-testid="input-article-minutes"
/>
<p className="text-xs text-gray-500 mt-1">How many minutes ago was this published</p>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isFeatured"
checked={articleForm.isFeatured}
onChange={(e) => setArticleForm({ ...articleForm, isFeatured: e.target.checked })}
className="rounded border-gray-300"
data-testid="input-article-featured"
/>
<label htmlFor="isFeatured" className="text-sm font-medium text-gray-700">
Featured Article
</label>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6">
<Button
variant="outline"
onClick={() => {
setIsArticleDialogOpen(false);
setEditingArticle(null);
resetArticleForm();
}}
data-testid="button-cancel-article"
>
Cancel
</Button>
<Button
onClick={handleSubmitArticle}
disabled={!articleForm.title || !articleForm.slug || createArticleMutation.isPending || updateArticleMutation.isPending}
data-testid="button-save-article"
>
{createArticleMutation.isPending || updateArticleMutation.isPending ? "Saving..." : editingArticle ? "Update Article" : "Create Article"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -152,6 +152,49 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
app.patch('/api/articles/:id', isAuthenticated, async (req: any, res) => {
try {
const user = req.user;
if (!user || (user.role !== 'admin' && user.role !== 'superadmin')) {
return res.status(403).json({ message: "Insufficient permissions" });
}
const article = await storage.getArticleById(req.params.id);
if (!article) {
return res.status(404).json({ message: "Article not found" });
}
const articleData = insertArticleSchema.partial().parse(req.body);
const updated = await storage.updateArticle(req.params.id, articleData);
res.json(updated);
} catch (error) {
console.error("Error updating article:", error);
res.status(500).json({ message: "Failed to update article" });
}
});
app.delete('/api/articles/:id', isAuthenticated, async (req: any, res) => {
try {
const user = req.user;
if (!user || (user.role !== 'admin' && user.role !== 'superadmin')) {
return res.status(403).json({ message: "Insufficient permissions" });
}
const article = await storage.getArticleById(req.params.id);
if (!article) {
return res.status(404).json({ message: "Article not found" });
}
await storage.deleteArticle(req.params.id);
res.status(204).send();
} catch (error) {
console.error("Error deleting article:", error);
res.status(500).json({ message: "Failed to delete article" });
}
});
// Prediction market routes
app.get('/api/prediction-markets', async (req, res) => {
try {

View File

@ -52,8 +52,10 @@ export interface IStorage {
getArticles(): Promise<Article[]>;
getArticlesByOutlet(mediaOutletId: string): Promise<Article[]>;
getArticleBySlug(slug: string): Promise<Article | undefined>;
getArticleById(id: string): Promise<Article | undefined>;
createArticle(article: InsertArticle): Promise<Article>;
updateArticle(id: string, article: Partial<InsertArticle>): Promise<Article>;
deleteArticle(id: string): Promise<void>;
getFeaturedArticles(limit?: number): Promise<Article[]>;
// Prediction market operations
@ -205,6 +207,15 @@ export class DatabaseStorage implements IStorage {
.orderBy(desc(articles.publishedAt))
.limit(limit);
}
async getArticleById(id: string): Promise<Article | undefined> {
const [article] = await db.select().from(articles).where(eq(articles.id, id));
return article;
}
async deleteArticle(id: string): Promise<void> {
await db.delete(articles).where(eq(articles.id, id));
}
// Prediction market operations
async getPredictionMarkets(articleId?: string): Promise<PredictionMarket[]> {