使用 React 和 Supabase 构建自定义调度器
介绍
技术栈
正在开发应用程序
苏帕巴
边缘函数
定时任务
应用程序的工作原理
附加功能
结论
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
介绍
调度是现代应用程序的关键功能之一。它使我们能够运行可自动化的周期性任务,例如发送提醒、安排帖子发布、更新数据或自动化工作流程。
因此,在本文中,我们将构建一个调度器,用于在 dev.to 上发布文章。虽然 dev.to 本身具有调度功能,但我们将以我们自己的方式实现这些功能,这种方式可以用于构建任何类型的调度应用程序。
那么,我们开始吧。
技术栈
我们将使用以下技术栈:
- React:我们将使用 React,特别是 ViteJS 与 React 结合来构建前端。
- Supabase:它提供了一套完整的应用程序构建解决方案,包括数据库、身份验证、存储、边缘计算等诸多功能。我们将使用 Supabase 的以下功能:
- 数据库:用于存储文章信息和日程安排时间。
- 定时任务:用于定期运行以调用 Edge 函数
- 边缘函数:此函数会检查是否有文章的预定发布时间与当前时间一致。如果一致,则会发布该文章。
这足以轻松构建一个调度程序应用程序。
正在开发应用程序
我们来讨论一下这个应用程序的工作原理,这样就很容易理解它的运行流程了。以下是流程图:
- 通过前端向数据库添加文章。
- Cron 作业将每分钟运行一次,以调用边缘函数。
- 系统将执行一个边缘函数,检查当前时间是否为已安排的文章发布时间。如果存在已安排的文章,则会发布该文章。
- 文章数据将在文章表格中更新。# 构建前端
最近,前端开发领域涌现出大量生成式人工智能工具,开发活动也随之减少。我们将要使用的其中一款人工智能工具是bolt.new。为什么选择 bolt.new 呢?因为它能够生成完整的 React 应用,包括所有依赖项和配置,例如 Tailwind CSS。您可以直接使用 StackBlitz 编辑文章并部署应用。如有需要,您还可以下载代码在本地运行。更棒的是,它与 Supabase 集成得非常好,因此您可以利用 Supabase 集成生成可运行的 React 应用。
我用它生成了首页。以下是所有页面。
App.tsx
这将处理用于显示组件和提供登录页面的页面。
function App() {
const [posts, setPosts] = useState<ScheduledPost[]>([]);
const handleSchedulePost = async (data: CreatePostData) => {
// In a real app, this would make an API call to your edge function
const newPost: ScheduledPost = {
content: data.content,
scheduled_time: data.scheduledTime,
status: 'pending',
title: data.title,
tags: data.tags
};
const { error } = await supabase
.from('scheduled_posts')
.insert(newPost)
if (error){
alert(`Erorr: ${error}`)
return
}
// setPosts((prev) => [...prev, newPost]);
};
const fetchScheduedPost = async () => {
const { data, error } = await supabase
.from('scheduled_posts')
.select()
if(error){
alert(`Erorr Fetching Data: ${error}`)
return
}
setPosts(data)
}
useEffect(() => {
fetchScheduedPost()
},[])
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center gap-2">
<Newspaper className="h-8 w-8 text-blue-500" />
<h1 className="text-xl font-bold text-gray-900">Dev.to Post Scheduler</h1>
</div>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-8">
<div className="grid gap-8 md:grid-cols-2">
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">Schedule New Post</h2>
<PostForm onSubmit={handleSchedulePost} />
</div>
<div>
<ScheduledPosts posts={posts} />
</div>
</div>
</main>
</div>
);
}
export default App;
SchudledPost.tsx
此处显示已安排的文章。
const StatusIcon = ({ status }: { status: ScheduledPost['status'] }) => {
switch (status) {
case 'posted':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'failed':
return <XCircle className="h-5 w-5 text-red-500" />;
default:
return <Clock3 className="h-5 w-5 text-yellow-500" />;
}
};
export function ScheduledPosts({ posts }: ScheduledPostsProps) {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-800">Scheduled Posts</h2>
{posts.length === 0 ? (
<p className="text-gray-500 text-center py-8">No scheduled posts yet</p>
) : (
<div className="space-y-4">
{posts.map((post, index) => (
<div
key={index}
className="bg-white p-4 rounded-lg shadow-md border border-gray-100"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-gray-800 mb-2">{post.title}</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(post.scheduled_time).toLocaleDateString()}
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{new Date(post.scheduled_time).toLocaleTimeString()}
</div>
</div>
</div>
<StatusIcon status={post.status} />
</div>
</div>
))}
</div>
)}
</div>
);
}
PostForm.tsx
这将处理用户可以提供有关文章信息的表单。
export function PostForm({ onSubmit }: PostFormProps) {
const [content, setContent] = useState('');
const [title, setTitle] = useState('');
const [tags, setTags] = useState<string[]>(['javascript', 'react']);
const [scheduledTime, setScheduledTime] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ content, title, scheduledTime, tags });
setContent('');
setTitle('');
setScheduledTime('');
setTags([]);
};
const handleTagChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedOptions = Array.from(e.target.selectedOptions);
const selectedTags = selectedOptions.map(option => option.value);
if(tags.length<4){
setTags(prevTags => {
const newTags = selectedTags.filter(tag => !prevTags.includes(tag));
return [...prevTags, ...newTags];
});
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags.filter(tag => tag !== tagToRemove));
};
return (
<form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow-md">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
Post Title
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Title of the post"
required
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
Post Content
</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={4}
maxLength={280}
placeholder="What's happening?"
required
/>
</div>
<div>
<label htmlFor="scheduledTime" className="block text-sm font-medium text-gray-700 mb-2">
Schedule Time
</label>
<div className="relative">
<input
type="datetime-local"
id="scheduledTime"
value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent pl-10"
required
/>
<Calendar className="absolute left-3 top-3.5 h-5 w-5 text-gray-400" />
</div>
</div>
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-2">
Tags
</label>
<div className="flex flex-wrap gap-2">
{tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs"
>
{tag}
<button
type="button"
className="ml-1 text-gray-500 hover:text-gray-700"
onClick={() => removeTag(tag)}
>
x
</button>
</span>
))}
<select
id="tags"
value={tags}
onChange={handleTagChange}
multiple
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
size={4}
required
>
{tagOptions.map((tag) => (
<option key={tag.value} value={tag.value}>
{tag.label}
</option>
))}
</select>
</div>
<div className="text-sm text-gray-500 mt-1">
Select up to 4 tags
</div>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-3 px-4 rounded-lg hover:bg-blue-600 transition-colors flex items-center justify-center gap-2"
>
<Send className="h-5 w-5" />
Schedule Post
</button>
</form>
);
}
最后我会把全部代码以 GitHub 仓库的形式提供给大家。
现在,我们来看看Supbase集成。
苏帕巴
首先,如果您还没有 Supabase 账户,请创建一个。您可以参考这篇文章了解如何在 Supabase 上创建账户:使用 LangChain 和 Supabase 将 ChatGPT 与您自己的数据结合使用。
创建表 scheduled_post。您可以使用以下 SQL 代码在 SQL 编辑器中运行以创建表,也可以使用表编辑器创建表。
create table
public.scheduled_posts (
id serial not null,
content text not null,
scheduled_time timestamp with time zone not null,
status text null default 'pending'::text,
created_at timestamp without time zone null default now(),
title character varying null,
devto_article_id character varying null,
posted_at character varying null,
tags character varying[] null,
error_message character varying null,
constraint scheduled_posts_pkey primary key (id)
) tablespace pg_default;
create index if not exists idx_scheduled_time_status on public.scheduled_posts using btree (scheduled_time, status) tablespace pg_default;
边缘函数
边缘函数是服务器端的 TypeScript 函数,它们分布在全球边缘——靠近用户。它们可用于监听 Webhook 或将您的 Supabase 项目与Stripe 等第三方服务集成。边缘函数使用Deno开发。
要在本地运行和部署边缘函数,您需要具备以下条件:
- Supbase CLI:您可以按照本指南在本地安装 CLI。只需使用 npm 和 npx 即可轻松完成。
- Docker Desktop:从这里安装 Docker Desktop 。
因此,安装完成后,您可以使用前端代码目录或其他目录来创建 Supabase Edge 函数。
运行以下命令以启动一个 Superbase 项目:
npx supabase init
可以使用以下命令创建 Edge 函数
supabase functions new xscheduler
上述命令将在 supabase 目录下创建 functions/xscheduler 目录。您可以在该目录中找到 index.ts 文件。Edge 函数使用 Deno 环境。
以下代码用于计算边缘函数:
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
// Ensure these are set in your environment variables
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || "";
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
const DEVTO_ACCESS_TOKEN = Deno.env.get("DEVTO_ACCESS_TOKEN") || "";
// Initialize Supabase client
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
// Function to post a new article to Dev.to
async function postToDevTo(title: string, content: string) {
const url = "https://dev.to/api/articles";
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"api-key": DEVTO_ACCESS_TOKEN,
},
body: JSON.stringify({ article: { title, body_markdown: content, published: true } })
});
if (response.ok) {
const result = await response.json();
console.log("Article posted successfully:", result);
return {
success: true,
articleId: result.id,
};
} else {
const errorBody = await response.text();
console.error("Dev.to API Error:", errorBody);
return {
success: false,
error: errorBody,
};
}
} catch (error) {
console.error("Error posting to Dev.to:", error);
return {
success: false,
error: error.message,
};
}
}
// Serve an HTTP endpoint for testing
serve(async (req) => {
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
try {
// Get the current timestamp rounded to the nearest minute
const currentDate = new Date();
const currentHour = currentDate.getHours();
const currentMinute = currentDate.getMinutes();
const currentDay = currentDate.getDate();
const currentMonth = currentDate.getMonth() + 1;
const currentYear = currentDate.getFullYear();
const currentDateTimeString = `${currentYear}-${currentMonth}-${currentDay} ${currentHour}:${currentMinute - 1}`;
const nextDateTimeString = `${currentYear}-${currentMonth}-${currentDay} ${currentHour}:${currentMinute + 1}`;
// Fetch articles scheduled for the current time from Supabase
const { data: articles, error: fetchError } = await supabase
.from("scheduled_posts")
.select()
.gt("scheduled_time", currentDateTimeString)
.lt("scheduled_time", nextDateTimeString) // Check within the next minute
if (fetchError) {
console.error("Error fetching scheduled articles:", fetchError);
return new Response(JSON.stringify({ message: "Error fetching scheduled articles" }), { status: 500 });
}
if (!articles || articles.length === 0) {
return new Response(JSON.stringify({ message: "No articles scheduled for posting." }), { status: 404 });
}
// Post each article to Dev.to and update its status in Supabase
for (const article of articles) {
const result = await postToDevTo(article.title, article.content);
if (result.success) {
// Update the article status in Supabase
const { error } = await supabase
.from("scheduled_posts")
.update({
status: "posted",
devto_article_id: result.articleId,
posted_at: new Date().toISOString(),
})
.eq("id", article.id);
if (error) {
console.error("Failed to update article status in Supabase", error);
return new Response(
JSON.stringify({ message: "Failed to update article status", error: error.message }),
{ status: 500 }
);
}
console.log(`Article ${article.id} posted successfully on Dev.to.`);
} else {
console.error(`Failed to post article ${article.id}:`, result.error);
return new Response(
JSON.stringify({ message: `Failed to post article ${article.id}`, error: result.error }),
{ status: 500 }
);
}
}
return new Response(
JSON.stringify({ message: "All scheduled articles posted successfully." }),
{ status: 200 }
);
} catch (error) {
console.error("Unexpected error:", error);
return new Response(
JSON.stringify({ message: "Internal Server Error", error: error.message }),
{ status: 500 }
);
}
});
SUPABASE_URL 和 SUPABASE_SERVICE_ROLE_KEY 等 ENV 参数会自动提供给您。DEVTO_ACCESS_TOKEN 参数您可以从此处生成,然后前往“项目设置”→“边缘函数”添加该令牌。此令牌将在 Deno 环境中可用。
您可以参考本指南来部署所需的边缘功能。
定时任务
Supbase 最近更新了 Cron 作业功能。现在您可以使用控制面板创建 Cron 作业,以前您需要编写代码才能完成此操作。您可以创建可以运行以下内容的作业:
- SQL 代码片段
- 数据库功能
- HTTP 请求
- 下边缘函数
我们将使用边缘函数,您可以添加边缘函数的详细信息,例如名称和授权,并将匿名密钥作为 Bearer Token。
应用程序的工作原理
现在我们已经创建了应用程序,接下来让我们看看它是如何运行的。使用以下命令运行前端:
npm run dev
添加标题、内容、时间和标签等详细信息。添加完成后,点击“定时发布”。定时任务会在文章预定发布时间与当前时间匹配时每分钟运行一次,文章随即发布。
当时间范围匹配时,文章将发布在 dev.to 上。
附加功能
使用上述方法,您可以为任何平台(例如 X、Instagram、LinkedIn 等)构建日程安排应用程序。您可以对其进行开发并添加以下功能:
- 图片:使用 supabase 存储上传和获取缩略图图片。
- 从 SQL 调用边缘函数:您可以通过从 SQL 代码片段或数据库函数调用边缘函数来进一步提高效率。这样,只有当文章与当前时间匹配时,才会调用边缘函数。
您可以在这里查看该项目在 GitHub 上的代码。
结论
创建日程安排应用程序可以简化自动化任务,例如发布文章、发送提醒和管理工作流程。我们使用 React 作为前端,Supabase 作为后端,构建了一个可扩展的解决方案,该方案充分利用了数据库、定时任务和边缘函数。这种方法可以适应各种用例,从而实现高效的自动化。借助这些工具,您可以构建功能强大的、满足您需求的日程安排应用程序。
希望这篇文章能帮助你理解定时任务(cron job)。感谢阅读。
文章来源:https://dev.to/surajondev/building-a-custom-scheduler-using-react-and-supabase-859


