Skip to content
Merged
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
15 changes: 14 additions & 1 deletion app/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,21 @@ export default async function PublicProfile({

return (
<main className={`min-h-screen relative px-4 py-16 theme-${user.theme || "default"}`}>
{user.backgroundImage && (
<>
<div
className="fixed inset-0 z-[-2]"
style={{
backgroundImage: `url(${user.backgroundImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<div className="fixed inset-0 z-[-1] bg-black/40 backdrop-blur-[2px]" />
</>
)}
<ShareProfileButton />
<div className="mx-auto max-w-md">
<div className="mx-auto max-w-md relative z-10">
<ProfileCard
user={{
name: user.name,
Expand Down
33 changes: 33 additions & 0 deletions app/api/user/background/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { revalidateTag } from "next/cache";

export async function PATCH(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await req.json();
const { backgroundImage } = body;

if (backgroundImage !== null && typeof backgroundImage !== "string") {
return NextResponse.json({ error: "Invalid backgroundImage" }, { status: 400 });
}

const updatedUser = await prisma.user.update({
where: { email: session.user.email },
data: { backgroundImage },
});

revalidateTag("public-profile", "default");

return NextResponse.json({ success: true, backgroundImage: updatedUser.backgroundImage }, { status: 200 });
} catch (error) {
console.error("Failed to update background image:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
62 changes: 62 additions & 0 deletions app/dashboard/AppearanceSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,24 @@ import { LayoutStyle } from "@/app/[username]/types/type";
export function AppearanceSection({
initialTheme,
initialLayout,
initialBackgroundImage,
onUpdateTheme,
onUpdateLayout,
onUpdateBackgroundImage,
}: {
initialTheme: string;
initialLayout: LayoutStyle;
initialBackgroundImage?: string;
onUpdateTheme: (theme: string) => void;
onUpdateLayout: (layout: LayoutStyle) => void;
onUpdateBackgroundImage: (url: string | null) => void;
}) {
const [selectedTheme, setSelectedTheme] = useState(initialTheme || "default");
const [selectedLayout, setSelectedLayout] = useState(initialLayout || "LIST");
const [backgroundImage, setBackgroundImage] = useState(initialBackgroundImage || "");
const [savingTheme, setSavingTheme] = useState(false);
const [savingLayout, setSavingLayout] = useState(false);
const [savingBg, setSavingBg] = useState(false);

async function handleSaveTheme(themeId: string) {
setSelectedTheme(themeId);
Expand Down Expand Up @@ -90,6 +96,34 @@ export function AppearanceSection({
}
}

async function handleSaveBackgroundImage() {
setSavingBg(true);
try {
const csrfToken = await getCsrfToken();
const res = await fetch("/api/user/background", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"x-csrf-token": csrfToken,
},
body: JSON.stringify({ backgroundImage: backgroundImage || null }),
});

if (!res.ok) {
throw new Error("Failed to save background image");
}

const data = await res.json();
onUpdateBackgroundImage(data.backgroundImage);
toast.success("Background image updated!");
} catch (error) {
toast.error("Failed to update background image");
console.error(error);
} finally {
setSavingBg(false);
}
}

return (
<section className="space-y-10">
{/* Layout Section */}
Expand Down Expand Up @@ -169,6 +203,34 @@ export function AppearanceSection({
))}
</div>
</div>

{/* Background Image Section */}
<div className="space-y-6">
<div className="space-y-1">
<h2 className="text-xl font-semibold">Custom Background</h2>
<p className="text-sm text-muted-foreground">
Set a custom background image URL for your public profile.
</p>
</div>
<div className="max-w-md flex gap-2">
<input
type="url"
aria-label="Background image URL"
placeholder="https://example.com/image.jpg"
className="flex-1 px-3 py-2 border rounded-md text-sm"
value={backgroundImage || ""}
onChange={(e) => setBackgroundImage(e.target.value)}
disabled={savingBg}
/>
<button
onClick={handleSaveBackgroundImage}
disabled={savingBg}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium disabled:opacity-50"
>
{savingBg ? "Saving..." : "Save"}
</button>
</div>
</div>
</section>
);
}
5 changes: 5 additions & 0 deletions app/dashboard/DashboardClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default function DashboardClient({
initialSeoTitle,
initialSeoDescription,
initialLayout,
initialBackgroundImage,
qrCode,
enableEmailCapture,
subscribers = [],
Expand All @@ -29,13 +30,15 @@ export default function DashboardClient({
initialSeoTitle?: string;
initialSeoDescription?: string;
initialLayout?: LayoutStyle;
initialBackgroundImage?: string | null;
qrCode?: React.ReactNode;
enableEmailCapture?: boolean;
subscribers?: { id: string; email: string; createdAt: Date }[];
}) {
const [links, setLinks] = useState(initialLinks);
const [theme, setTheme] = useState(initialTheme || "default");
const [layoutStyle, setLayoutStyle] = useState<LayoutStyle>(initialLayout || "LIST");
const [backgroundImage, setBackgroundImage] = useState<string | null>(initialBackgroundImage || "");
const [seoTitle, setSeoTitle] = useState(initialSeoTitle || "");
const [seoDescription, setSeoDescription] = useState(initialSeoDescription || "");
const [activeTab, setActiveTab] = useState<"links" | "appearance" | "seo">("links");
Expand Down Expand Up @@ -399,8 +402,10 @@ export default function DashboardClient({
<AppearanceSection
initialTheme={theme}
initialLayout={layoutStyle}
initialBackgroundImage={backgroundImage ?? undefined}
onUpdateTheme={setTheme}
onUpdateLayout={setLayoutStyle}
onUpdateBackgroundImage={setBackgroundImage}
/>
) : (
<SeoSection
Expand Down
1 change: 1 addition & 0 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default async function DashboardPage() {
initialLinks={nestedLinks}
initialTheme={user.theme}
initialLayout={user.layoutStyle}
initialBackgroundImage={user.backgroundImage}
initialSeoTitle={user.seoTitle || ""}
initialSeoDescription={user.seoDescription || ""}
qrCode={<QRCode />}
Expand Down
20 changes: 19 additions & 1 deletion lib/profileWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface ProfileSnapshot {
themeType: string | null;
themeColor: string | null;
themeCustom: string | null;
backgroundImage: string | null;
}

/**
Expand Down Expand Up @@ -59,6 +60,7 @@ export async function upsertProfileDraft(
themeType: draft.themeType,
themeColor: draft.themeColor,
themeCustom: draft.themeCustom,
backgroundImage: draft.backgroundImage,
};
}

Expand All @@ -77,7 +79,7 @@ export async function getEditableProfileState(
// a subset of fields are being edited in the draft).
const user = await prisma.user.findUnique({
where: { id: userId },
select: { name: true, username: true, bio: true, image: true, themeType: true, themeColor: true, themeCustom: true },
select: { name: true, username: true, bio: true, image: true, themeType: true, themeColor: true, themeCustom: true, backgroundImage: true },
});

// If a draft exists but the live user record is missing, the system
Expand All @@ -96,6 +98,7 @@ export async function getEditableProfileState(
themeType: draft.themeType ?? user.themeType ?? null,
themeColor: draft.themeColor ?? user.themeColor ?? null,
themeCustom: draft.themeCustom ?? user.themeCustom ?? null,
backgroundImage: draft.backgroundImage ?? user.backgroundImage ?? null,
};
}

Expand All @@ -109,6 +112,7 @@ export async function getEditableProfileState(
themeType: true,
themeColor: true,
themeCustom: true,
backgroundImage: true,
},
});

Expand All @@ -124,6 +128,7 @@ export async function getEditableProfileState(
themeType: user.themeType,
themeColor: user.themeColor,
themeCustom: user.themeCustom,
backgroundImage: user.backgroundImage,
};
}

Expand Down Expand Up @@ -182,6 +187,9 @@ function diffProfileSnapshots(
if (before.themeCustom !== after.themeCustom) {
diff.themeCustom = { before: before.themeCustom, after: after.themeCustom };
}
if (before.backgroundImage !== after.backgroundImage) {
diff.backgroundImage = { before: before.backgroundImage, after: after.backgroundImage };
}

return diff;
}
Expand Down Expand Up @@ -243,6 +251,7 @@ export async function publishProfileDraft(
themeType: true,
themeColor: true,
themeCustom: true,
backgroundImage: true,
},
});

Expand All @@ -259,6 +268,7 @@ export async function publishProfileDraft(
themeType: draft.themeType ?? user.themeType,
themeColor: draft.themeColor ?? user.themeColor,
themeCustom: draft.themeCustom ?? user.themeCustom,
backgroundImage: draft.backgroundImage ?? user.backgroundImage,
};

// Calculate diff
Expand Down Expand Up @@ -287,6 +297,7 @@ export async function publishProfileDraft(
themeType: afterSnapshot.themeType ?? "solid",
themeColor: afterSnapshot.themeColor ?? "slate",
themeCustom: afterSnapshot.themeCustom,
backgroundImage: afterSnapshot.backgroundImage,
},
});

Expand All @@ -301,6 +312,7 @@ export async function publishProfileDraft(
themeType: afterSnapshot.themeType ?? "solid",
themeColor: afterSnapshot.themeColor ?? "slate",
themeCustom: afterSnapshot.themeCustom,
backgroundImage: afterSnapshot.backgroundImage,
changeType: "publish",
diffJson: JSON.stringify(diff),
},
Expand Down Expand Up @@ -399,6 +411,7 @@ export async function resolvePreviewToken(
themeType: draft.themeType,
themeColor: draft.themeColor,
themeCustom: draft.themeCustom,
backgroundImage: draft.backgroundImage,
};

return {
Expand Down Expand Up @@ -427,6 +440,7 @@ export async function getProfileVersions(userId: string, limit: number = 20) {
themeType: v.themeType,
themeColor: v.themeColor,
themeCustom: v.themeCustom,
backgroundImage: v.backgroundImage,
},
changeType: v.changeType,
diff: v.diffJson ? JSON.parse(v.diffJson) : {},
Expand Down Expand Up @@ -462,6 +476,7 @@ export async function rollbackProfileVersion(
themeType: true,
themeColor: true,
themeCustom: true,
backgroundImage: true,
},
});

Expand All @@ -478,6 +493,7 @@ export async function rollbackProfileVersion(
themeType: version.themeType,
themeColor: version.themeColor,
themeCustom: version.themeCustom,
backgroundImage: version.backgroundImage,
};

// Calculate diff
Expand Down Expand Up @@ -510,6 +526,7 @@ export async function rollbackProfileVersion(
themeType: afterSnapshot.themeType ?? "solid",
themeColor: afterSnapshot.themeColor ?? "slate",
themeCustom: afterSnapshot.themeCustom,
backgroundImage: afterSnapshot.backgroundImage,
},
});

Expand All @@ -524,6 +541,7 @@ export async function rollbackProfileVersion(
themeType: afterSnapshot.themeType ?? "solid",
themeColor: afterSnapshot.themeColor ?? "slate",
themeCustom: afterSnapshot.themeCustom,
backgroundImage: afterSnapshot.backgroundImage,
changeType: "rollback",
diffJson: JSON.stringify(diff),
},
Expand Down
1 change: 1 addition & 0 deletions lib/userLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const publicProfileSelect = {
username: true,
bio: true,
image: true,
backgroundImage: true,
theme: true,
themeType: true,
themeColor: true,
Expand Down
8 changes: 8 additions & 0 deletions prisma/migrations/20260805_add_background_image/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "backgroundImage" TEXT;

-- AlterTable
ALTER TABLE "ProfileDraft" ADD COLUMN "backgroundImage" TEXT;

-- AlterTable
ALTER TABLE "ProfileVersion" ADD COLUMN "backgroundImage" TEXT;
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ model User {
email String @unique
emailVerified DateTime?
image String?
backgroundImage String?

password String?

Expand Down Expand Up @@ -214,6 +215,7 @@ model ProfileDraft {
username String?
bio String?
image String?
backgroundImage String?
themeType String?
themeColor String?
themeCustom String?
Expand All @@ -232,6 +234,7 @@ model ProfileVersion {
username String?
bio String?
image String?
backgroundImage String?
themeType String?
themeColor String?
themeCustom String?
Expand Down
Loading