Files
Sahaj Jain 249b72f98a Community themes (#259)
* add community themes feature

Allow users to publish saved themes to a public community page,
browse/sort community themes, and like them with auth-gated interactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* improve community themes UI and fix like state

- Polish community page with better layout, sort icons, loading skeletons
- Show author, date, like count on theme detail page
- Fix isLikedByMe not being fetched in getCommunityDataForTheme
- Remove Community link from app header
- Replace Save with Publish button in editor action bar for saved themes
- Include isPublished in getThemes() via server-side join to avoid layout shift

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ui

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 01:32:17 +05:30

218 lines
6.3 KiB
TypeScript

"use server";
import { z } from "zod";
import { db } from "@/db";
import { theme as themeTable, communityTheme } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm";
import cuid from "cuid";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { themeStylesSchema, type ThemeStyles } from "@/types/theme";
import { cache } from "react";
import {
UnauthorizedError,
ValidationError,
ThemeNotFoundError,
ErrorCode,
actionError,
actionSuccess,
type ActionResult,
} from "@/types/errors";
import { MAX_FREE_THEMES } from "@/lib/constants";
import { getMyActiveSubscription } from "@/lib/subscription";
// Helper to get user ID with better error handling
async function getCurrentUserId(): Promise<string> {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user?.id) {
throw new UnauthorizedError();
}
return session.user.id;
}
// Log errors for observability
function logError(error: Error, context: Record<string, any>) {
console.error("Theme action error:", error, context);
// TODO: Add server-side error reporting to PostHog or your preferred service
// For production, you'd want to send critical errors to an external service
if (error.name === "UnauthorizedError" || error.name === "ValidationError") {
// These are expected errors, log but don't report
console.warn("Expected error:", { error: error.message, context });
} else {
// Unexpected errors should be reported
console.error("Unexpected error:", { error: error.message, stack: error.stack, context });
}
}
const createThemeSchema = z.object({
name: z.string().min(1, "Theme name cannot be empty").max(50, "Theme name too long"),
styles: themeStylesSchema,
});
const updateThemeSchema = z.object({
id: z.string().min(1, "Theme ID required"),
name: z.string().min(1, "Theme name cannot be empty").max(50, "Theme name too long").optional(),
styles: themeStylesSchema.optional(),
});
// Layer 1: Clean server actions with proper error handling
export async function getThemes() {
try {
const userId = await getCurrentUserId();
const userThemes = await db
.select({
id: themeTable.id,
userId: themeTable.userId,
name: themeTable.name,
styles: themeTable.styles,
createdAt: themeTable.createdAt,
updatedAt: themeTable.updatedAt,
isPublished: sql<boolean>`${communityTheme.id} is not null`.as(
"is_published"
),
})
.from(themeTable)
.leftJoin(communityTheme, eq(themeTable.id, communityTheme.themeId))
.where(eq(themeTable.userId, userId));
return userThemes;
} catch (error) {
logError(error as Error, { action: "getThemes" });
throw error;
}
}
export const getTheme = cache(async (themeId: string) => {
try {
if (!themeId) {
throw new ValidationError("Theme ID required");
}
const [theme] = await db.select().from(themeTable).where(eq(themeTable.id, themeId)).limit(1);
if (!theme) {
throw new ThemeNotFoundError();
}
return theme;
} catch (error) {
logError(error as Error, { action: "getTheme", themeId });
throw error;
}
});
export async function createTheme(formData: { name: string; styles: ThemeStyles }) {
try {
const userId = await getCurrentUserId();
const validation = createThemeSchema.safeParse(formData);
if (!validation.success) {
throw new ValidationError("Invalid input", validation.error.format());
}
// Check theme limit
const userThemes = await db.select().from(themeTable).where(eq(themeTable.userId, userId));
if (userThemes.length >= MAX_FREE_THEMES) {
const activeSubscription = await getMyActiveSubscription(userId);
const isSubscribed =
!!activeSubscription &&
activeSubscription?.productId === process.env.NEXT_PUBLIC_TWEAKCN_PRO_PRODUCT_ID;
if (!isSubscribed) {
return actionError(
ErrorCode.THEME_LIMIT_REACHED,
`You have reached the limit of ${MAX_FREE_THEMES} themes.`
);
}
}
const { name, styles } = validation.data;
const newThemeId = cuid();
const now = new Date();
const [insertedTheme] = await db
.insert(themeTable)
.values({
id: newThemeId,
userId: userId,
name: name,
styles: styles,
createdAt: now,
updatedAt: now,
})
.returning();
return actionSuccess(insertedTheme);
} catch (error) {
logError(error as Error, { action: "createTheme", formData: { name: formData.name } });
throw error;
}
}
export async function updateTheme(formData: { id: string; name?: string; styles?: ThemeStyles }) {
try {
const userId = await getCurrentUserId();
const validation = updateThemeSchema.safeParse(formData);
if (!validation.success) {
throw new ValidationError("Invalid input", validation.error.format());
}
const { id: themeId, name, styles } = validation.data;
if (!name && !styles) {
throw new ValidationError("No update data provided");
}
const updateData: Partial<typeof themeTable.$inferInsert> = {
updatedAt: new Date(),
};
if (name) updateData.name = name;
if (styles) updateData.styles = styles;
const [updatedTheme] = await db
.update(themeTable)
.set(updateData)
.where(and(eq(themeTable.id, themeId), eq(themeTable.userId, userId)))
.returning();
if (!updatedTheme) {
throw new ThemeNotFoundError("Theme not found or not owned by user");
}
return updatedTheme;
} catch (error) {
logError(error as Error, { action: "updateTheme", themeId: formData.id });
throw error;
}
}
export async function deleteTheme(themeId: string) {
try {
const userId = await getCurrentUserId();
if (!themeId) {
throw new ValidationError("Theme ID required");
}
const [deletedTheme] = await db
.delete(themeTable)
.where(and(eq(themeTable.id, themeId), eq(themeTable.userId, userId)))
.returning({ id: themeTable.id, name: themeTable.name });
if (!deletedTheme) {
throw new ThemeNotFoundError("Theme not found or not owned by user");
}
return deletedTheme;
} catch (error) {
logError(error as Error, { action: "deleteTheme", themeId });
throw error;
}
}