Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
509 changes: 269 additions & 240 deletions src/components/create-post-form.tsx

Large diffs are not rendered by default.

50 changes: 34 additions & 16 deletions src/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
import { GitHubIcon, XIcon } from "@daveyplate/better-auth-ui"
import { Heart } from "lucide-react"

export function Footer() {
return (
<footer className="border-t">
<div className="container mx-auto flex h-12 items-center justify-between">
<p className="text-muted-foreground text-sm">
Built by{" "}
<a href="https://x.com/emadev01" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">
EmaDev
</a>
.
</p>
<div className="flex items-center gap-4">
<a href="https://x.com/emadev01" target="_blank" rel="noopener noreferrer" aria-label="Emanuele's Twitter profile">
<XIcon className="h-5 w-5 text-muted-foreground hover:text-foreground" />
</a>
<a href="https://github.com/epavanello/better-plan" target="_blank" rel="noopener noreferrer" aria-label="GitHub repository">
<GitHubIcon className="h-5 w-5 text-muted-foreground hover:text-foreground" />
</a>
<footer className="border-t bg-muted/20">
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<div className="flex flex-col items-center gap-2 text-center md:flex-row md:text-left">
<p className="text-muted-foreground text-sm">
&copy; {new Date().getFullYear()} Better Plan. Built with <Heart className="inline h-4 w-4 text-red-500" /> by{" "}
<a href="https://x.com/emadev01" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">
EmaDev
</a>
</p>
<span className="hidden text-muted-foreground md:inline">•</span>
<p className="text-muted-foreground text-sm">AI-powered social media management</p>
</div>
<div className="flex items-center gap-4">
<a
href="https://x.com/emadev01"
target="_blank"
rel="noopener noreferrer"
aria-label="Emanuele's Twitter profile"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<XIcon className="h-5 w-5" />
</a>
<a
href="https://github.com/epavanello/better-plan"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<GitHubIcon className="h-5 w-5" />
</a>
</div>
</div>
</div>
</footer>
Expand Down
3 changes: 2 additions & 1 deletion src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export function Header() {
<header className="sticky top-0 z-50 border-b bg-background/60 px-4 py-3 backdrop-blur">
<div className="container mx-auto flex items-center justify-between">
<Link to="/" className="flex items-center gap-2">
<span className="font-bold">BETTER-PLAN.</span>
<span className="hidden font-bold md:block">BETTER-PLAN.</span>
<span className="block font-bold md:hidden">BP.</span>
</Link>

<div className="flex items-center gap-4">
Expand Down
166 changes: 166 additions & 0 deletions src/components/integrations/integrations-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { platformIcons } from "@/components/platform-icons"
import { Button } from "@/components/ui/button"
import type { Platform } from "@/database/schema/integrations"
import { deleteIntegration, deleteUserAppCredentials, type getIntegrations, getUserPlatformStatus } from "@/functions/integrations"
import type { getAllPlatformInfo } from "@/functions/platforms"
import { useMutation, useQuery } from "@tanstack/react-query"
import { PlusCircle, Settings, Trash2 } from "lucide-react"
import { toast } from "sonner"

interface IntegrationsListProps {
integrations: Awaited<ReturnType<typeof getIntegrations>>
platformsInfo: Awaited<ReturnType<typeof getAllPlatformInfo>>
platformSetups: Record<Platform, ReturnType<typeof usePlatformSetup>>
authorizingPlatform: Platform | null
onPlatformConnect: (platform: Platform) => void
onSetupPlatform: (platform: Platform) => void
onIntegrationRemoved: () => void
}

// Hook for platform setup (moved from main component)
function usePlatformSetup(platform: Platform) {
const { data: platformStatus, refetch: refetchStatus } = useQuery({
queryKey: ["platform-status", platform],
queryFn: () => getUserPlatformStatus({ data: platform }),
enabled: true
})

const { mutate: removeCredentials, isPending: isRemoving } = useMutation({
mutationFn: deleteUserAppCredentials,
onSuccess: () => {
toast.success("User credentials removed successfully.")
refetchStatus()
}
})

return {
requiresSetup: platformStatus?.requiresSetup || false,
hasCredentials: platformStatus?.hasCredentials || false,
canConnect: platformStatus?.canConnect ?? true,
redirectUrl: platformStatus?.redirectUrl,
credentialSource: platformStatus?.source,
refetchStatus,
removeCredentials,
isRemovingCredentials: isRemoving
}
}

export function IntegrationsList({
integrations,
platformsInfo,
platformSetups,
authorizingPlatform,
onPlatformConnect,
onSetupPlatform,
onIntegrationRemoved
}: IntegrationsListProps) {
const { mutate: remove, isPending: isRemovePending } = useMutation({
mutationFn: deleteIntegration,
onSuccess: () => {
toast.success("Integration removed successfully.")
onIntegrationRemoved()
}
})

const connectedPlatforms = integrations.map((i) => i.platform)

return (
<div className="space-y-8">
<div>
<h2 className="mb-4 font-semibold text-lg">Connected</h2>
{integrations.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{integrations.map((integration) => {
const platformInfo = platformsInfo.find((p) => p.name === integration.platform)
return (
<div key={integration.id} className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-4">
{platformIcons[integration.platform]}
<div>
<p className="font-semibold">{platformInfo?.displayName || integration.platform}</p>
<p className="text-muted-foreground text-sm">{integration.platformAccountName}</p>
</div>
</div>
<Button variant="destructive" size="icon" onClick={() => remove({ data: integration.id })} disabled={isRemovePending}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
) : (
<p className="text-muted-foreground">No integrations connected yet.</p>
)}
</div>

<div>
<h2 className="mb-4 font-semibold text-lg">Available</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{platformsInfo.map((platformInfo) => {
const setupInfo = platformSetups[platformInfo.name]
const isCurrentlyAuthenticating = authorizingPlatform === platformInfo.name

return (
<div key={platformInfo.name} className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{platformIcons[platformInfo.name]}
<p className="font-semibold">{platformInfo.displayName}</p>
</div>

{platformInfo.isImplemented ? (
setupInfo && platformInfo.requiresSetup && !setupInfo.hasCredentials ? (
<Button variant="outline" onClick={() => onSetupPlatform(platformInfo.name)}>
<Settings className="mr-2 h-4 w-4" />
Setup
</Button>
) : (
<Button onClick={() => onPlatformConnect(platformInfo.name)} disabled={isCurrentlyAuthenticating}>
<PlusCircle className="mr-2 h-4 w-4" />
{isCurrentlyAuthenticating ? "Redirecting..." : "Connect"}
</Button>
)
) : (
<p className="text-muted-foreground">Coming soon!</p>
)}
</div>

{/* Setup information */}
{platformInfo.requiresSetup && setupInfo && (
<div className="text-sm">
{setupInfo.hasCredentials ? (
<div className="flex items-center justify-between rounded bg-green-50 px-3 py-2 dark:bg-green-950/20">
<span className="text-green-700 dark:text-green-300">
{setupInfo.credentialSource === "system" ? "System configured ✓" : "App configured ✓"}
</span>
{setupInfo.credentialSource === "user" && (
<Button
variant="ghost"
size="sm"
onClick={() =>
setupInfo.removeCredentials?.({
data: platformInfo.name
})
}
disabled={setupInfo.isRemovingCredentials}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
) : (
<div className="rounded bg-amber-50 px-3 py-2 text-amber-700 dark:bg-amber-950/20 dark:text-amber-300">
Requires app configuration
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
76 changes: 8 additions & 68 deletions src/functions/posts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { db } from "@/database/db"
import { type InsertPost, type InsertPostDestination, postDestinations, posts } from "@/database/schema"
import { type InsertPost, posts } from "@/database/schema"
import { PLATFORM_VALUES, type Platform, integrations } from "@/database/schema/integrations"
import { getSessionOrThrow } from "@/lib/auth"
import { getEffectiveCredentials } from "@/lib/server/integrations"
import { postToSocialMedia } from "@/lib/server/post-service"
import type { PostDestination } from "@/lib/server/social-platforms/base-platform"
import { PostDestinationService } from "@/lib/server/posts/destination-service"
import { DestinationSchema } from "@/lib/server/social-platforms/base-platform"
import { PlatformFactory } from "@/lib/server/social-platforms/platform-factory"
import { createServerFn } from "@tanstack/react-start"
import { and, desc, eq, sql } from "drizzle-orm"
Expand All @@ -14,22 +15,15 @@ const createPostSchema = z.object({
integrationId: z.string(),
content: z.string(),
scheduledAt: z.date().optional(),
destination: z
.object({
type: z.string(),
id: z.string(),
name: z.string(),
metadata: z.record(z.any()).optional(),
description: z.string().optional()
})
.optional(),
destination: DestinationSchema.optional(),
additionalFields: z.record(z.string()).optional()
})

export const createPost = createServerFn({ method: "POST" })
.validator(createPostSchema)
.handler(async ({ data }) => {
const session = await getSessionOrThrow()
const destinationService = new PostDestinationService()

const integration = await db.query.integrations.findFirst({
where: (integrations, { eq, and }) => and(eq(integrations.id, data.integrationId), eq(integrations.userId, session.user.id))
Expand Down Expand Up @@ -69,7 +63,7 @@ export const createPost = createServerFn({ method: "POST" })

// Save destination to recent destinations if provided
if (data.destination) {
await saveRecentDestination(session.user.id, integration.platform, data.destination)
await destinationService.saveRecentDestination(session.user.id, integration.platform, data.destination)
}

// If it's a draft, try to post immediately
Expand Down Expand Up @@ -105,69 +99,15 @@ export const createPost = createServerFn({ method: "POST" })
return post
})

async function saveRecentDestination(userId: string, platform: Platform, destination: PostDestination): Promise<void> {
const existingDestination = await db.query.postDestinations.findFirst({
where: (postDestinations, { eq, and }) =>
and(eq(postDestinations.userId, userId), eq(postDestinations.platform, platform), eq(postDestinations.destinationId, destination.id))
})

if (existingDestination) {
// Update existing destination
await db
.update(postDestinations)
.set({
useCount: sql`${postDestinations.useCount} + 1`,
lastUsedAt: new Date(),
destinationName: destination.name,
destinationMetadata: destination.metadata ? JSON.stringify(destination.metadata) : undefined,
updatedAt: new Date()
})
.where(eq(postDestinations.id, existingDestination.id))
} else {
// Create new destination
const newDestination: InsertPostDestination = {
userId,
platform,
destinationType: destination.type,
destinationId: destination.id,
destinationName: destination.name,
destinationMetadata: destination.metadata ? JSON.stringify(destination.metadata) : undefined,
lastUsedAt: new Date(),
useCount: 1
}
await db.insert(postDestinations).values(newDestination)
}
}

export const getRecentDestinations = createServerFn({ method: "POST" })
.validator((payload: { platform: Platform; limit?: number }) =>
z.object({ platform: z.enum(PLATFORM_VALUES), limit: z.number().optional() }).parse(payload)
)
.handler(async ({ data }) => {
const session = await getSessionOrThrow()
const destinationService = new PostDestinationService()

const recentDestinations = await db.query.postDestinations.findMany({
where: (postDestinations, { eq, and }) =>
and(eq(postDestinations.userId, session.user.id), eq(postDestinations.platform, data.platform)),
orderBy: (postDestinations, { desc }) => desc(postDestinations.lastUsedAt),
limit: data.limit || 10
})

return recentDestinations.map((dest) => {
let metadata: Record<string, unknown> | undefined
try {
metadata = dest.destinationMetadata ? JSON.parse(dest.destinationMetadata) : undefined
} catch {
metadata = undefined
}

return {
type: dest.destinationType,
id: dest.destinationId,
name: dest.destinationName,
description: metadata?.description as string | undefined
}
})
return await destinationService.getRecentDestinations(session.user.id, data.platform, data.limit || 10)
})

export const createDestinationFromInput = createServerFn({ method: "POST" })
Expand Down
Loading