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
24 changes: 24 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ model User {
passwordResetTokens PasswordResetToken[]
updatedCampaigns Campaign[] @relation("CampaignUpdatedBy")

activeThemeId String? @map("active_theme_id") @db.Uuid
activeTheme Theme? @relation("ActiveTheme", fields: [activeThemeId], references: [id], onDelete: SetNull)
themes Theme[]

@@map("users")
}

Expand Down Expand Up @@ -260,3 +264,23 @@ model Session {
@@index([isActive])
@@map("sessions")
}

model Theme {
id String @id @default(uuid()) @db.Uuid
authorId String @map("author_id") @db.Uuid
name String
description String?
isPublic Boolean @default(false) @map("is_public")

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
User User[] @relation("ActiveTheme")

colors Json

@@index([authorId])
@@index([isPublic])
@@map("themes")
}
168 changes: 168 additions & 0 deletions src/controllers/themeController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { Request, Response } from "express";
import { prisma } from "../lib/prisma.js";
import { SessionAuthRequest } from "../lib/sessionAuth.js";

export async function getThemes(req: Request, res: Response): Promise<void> {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.max(
1,
Math.min(50, parseInt(req.query.limit as string) || 24),
);
const skip = (page - 1) * limit;

const where = { isPublic: true };

const [totalItems, themes] = await Promise.all([
prisma.theme.count({ where }),
prisma.theme.findMany({
where,
include: {
author: {
select: { username: true },
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
]);

const totalPages = Math.ceil(totalItems / limit);

res.json({
themes,
pagination: {
page,
limit,
totalItems,
totalPages,
itemsReturned: themes.length,
},
});
} catch (error) {
console.error("Get themes error:", error);
res.status(500).json({ error: "Failed to get themes" });
}
}

export async function createTheme(
req: SessionAuthRequest,
res: Response,
): Promise<void> {
try {
const { name, description, isPublic, colors } = req.body;

const theme = await prisma.theme.create({
data: {
name,
description: description || null,
isPublic,
authorId: req.user!.id,
colors,
},
});

if (!theme.isPublic) {
await prisma.user.update({
where: { id: req.user!.id },
data: { activeThemeId: theme.id },
});
res.setHeader(
"Set-Cookie",
`theme=${encodeURIComponent(JSON.stringify(theme.colors))}; path=/; max-age=31536000`,
);
}

res.status(201).json(theme);
} catch (error) {
console.error("Create theme error:", error);
res.status(500).json({ error: "Failed to create theme" });
}
}

export async function getUserTheme(
req: SessionAuthRequest,
res: Response,
): Promise<void> {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
include: { activeTheme: true },
});

if (!user?.activeTheme) {
res.json({ theme: null });
return;
}

res.json({ theme: user.activeTheme.colors });
} catch (error) {
console.error("Get user theme error:", error);
res.status(500).json({ error: "Failed to get user theme" });
}
}

export async function updateUserTheme(
req: SessionAuthRequest,
res: Response,
): Promise<void> {
try {
const { themeId } = req.body;

const theme = await prisma.theme.findUnique({
where: { id: themeId },
});

if (!theme) {
res.status(404).json({ error: "Theme not found" });
return;
}

if (!theme.isPublic && theme.authorId !== req.user!.id) {
res
.status(403)
.json({ error: "Cannot apply a private theme you do not own" });
return;
}

const currentUser = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { activeThemeId: true },
});

if (currentUser?.activeThemeId !== theme.id) {
await prisma.user.update({
where: { id: req.user!.id },
data: { activeThemeId: theme.id },
});
}

res.json({ success: true, colors: theme.colors });
} catch (error) {
console.error("Update user theme error:", error);
res.status(500).json({ error: "Failed to update user theme" });
}
}

export async function clearUserTheme(
req: SessionAuthRequest,
res: Response,
): Promise<void> {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { activeThemeId: true },
});

await prisma.user.update({
where: { id: req.user!.id },
data: { activeThemeId: null },
});
res.setHeader("Set-Cookie", `theme=; path=/; max-age=0`);
res.json({ success: true });
} catch (error) {
console.error("Clear user theme error:", error);
res.status(500).json({ error: "Failed to clear user theme" });
}
}
26 changes: 26 additions & 0 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { z } from "zod";

const CSSColor = z.string().min(1);

export const ThemeColorsSchema = z.object({
primary: CSSColor,
primary100: CSSColor,
primary200: CSSColor,
primary300: CSSColor,
primary400: CSSColor,
primary500: CSSColor,
background: CSSColor,
foreground: CSSColor,
card: CSSColor,
cardForeground: CSSColor,
muted: CSSColor,
mutedForeground: CSSColor,
accent: CSSColor,
accentForeground: CSSColor,
destructive: CSSColor,
border: CSSColor,
input: CSSColor,
ring: CSSColor,
});

export type ThemeColors = z.infer<typeof ThemeColorsSchema>;
16 changes: 16 additions & 0 deletions src/middleware/validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import { z, ZodError } from 'zod';
import { ThemeColorsSchema } from '../lib/theme';

const passwordSchema = z
.string()
Expand Down Expand Up @@ -132,6 +133,21 @@ export const upsertCampaignSchema = z.object({
}),
});

export const createThemeSchema = z.object({
body: z.object({
name: z.string().trim().min(1, "Theme name is required").max(100),
description: z.string().trim().max(1000).optional().nullable(),
isPublic: z.boolean(),
colors: ThemeColorsSchema,
}),
});

export const updateUserThemeSchema = z.object({
body: z.object({
themeId: z.string().trim().min(1, "themeId string is required"),
}),
});

const formatZodErrors = (error: ZodError) =>
error.errors.map(({ path, message }) => ({
field: path.filter((p) => p !== 'body').join('.') || 'unknown',
Expand Down
30 changes: 30 additions & 0 deletions src/routes/themes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Router } from "express";
import { authenticateSession } from "../lib/sessionAuth.js";
import { registerRoute } from "../lib/docs.js";
import { getThemes, createTheme } from "../controllers/themeController.js";
import { validate, createThemeSchema } from "../middleware/validation.js";

const router = Router();

router.get("/", getThemes);
router.post("/", authenticateSession, validate(createThemeSchema), createTheme);

registerRoute({
method: "GET",
path: "/themes",
summary: "Get public themes",
responses: {
"200":
'{"themes":[{"id":"string","name":"string","author":{"username":"string"},"colors":{}}],"pagination":{"page":1,"limit":24,"totalItems":1,"totalPages":1,"itemsReturned":1}}',
},
});

registerRoute({
method: "POST",
path: "/themes",
summary: "Create a theme",
auth: true,
responses: { "200": '{"id":"string"}' },
});

export default router;
21 changes: 21 additions & 0 deletions src/routes/user-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Router } from "express";
import { authenticateSession } from "../lib/sessionAuth.js";
import {
getUserTheme,
updateUserTheme,
clearUserTheme,
} from "../controllers/themeController.js";
import { validate, updateUserThemeSchema } from "../middleware/validation.js";

const router = Router();

router.get("/", authenticateSession, getUserTheme);
router.patch(
"/",
authenticateSession,
validate(updateUserThemeSchema),
updateUserTheme,
);
router.delete("/", authenticateSession, clearUserTheme);

export default router;