mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 13:55:44 -04:00
Replace title mutation API routes with server actions
Move all title page mutations (status, rating, movie watch, episode watch/unwatch, season watch/unwatch) from API route handlers to server actions called directly from TitleInteractionProvider. Same optimistic update pattern, no behavioral change — just eliminates the fetch() round-trip through 5 API routes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7 files changed
+92
-211
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth/server";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { episodes } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
logEpisodeWatch,
|
||||||
|
logMovieWatch,
|
||||||
|
rateTitleStars,
|
||||||
|
removeTitleStatus,
|
||||||
|
setTitleStatus,
|
||||||
|
unwatchEpisode,
|
||||||
|
unwatchSeason,
|
||||||
|
} from "@/lib/services/tracking";
|
||||||
|
|
||||||
|
async function getSessionUserId() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error("Unauthorized");
|
||||||
|
return session.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTitleStatus(
|
||||||
|
titleId: string,
|
||||||
|
status: string | null,
|
||||||
|
) {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
if (status === null || status === undefined) {
|
||||||
|
await removeTitleStatus(userId, titleId);
|
||||||
|
} else {
|
||||||
|
await setTitleStatus(
|
||||||
|
userId,
|
||||||
|
titleId,
|
||||||
|
status as "watchlist" | "in_progress" | "completed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTitleRating(titleId: string, ratingStars: number) {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
if (ratingStars < 0 || ratingStars > 5) throw new Error("Invalid rating");
|
||||||
|
await rateTitleStars(userId, titleId, ratingStars);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function watchMovie(titleId: string) {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
await logMovieWatch(userId, titleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function watchEpisode(episodeId: string) {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
await logEpisodeWatch(userId, episodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unwatchEpisodeAction(episodeId: string) {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
await unwatchEpisode(userId, episodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function watchSeason(seasonId: string) {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
const seasonEps = await db
|
||||||
|
.select()
|
||||||
|
.from(episodes)
|
||||||
|
.where(eq(episodes.seasonId, seasonId))
|
||||||
|
.all();
|
||||||
|
for (const ep of seasonEps) {
|
||||||
|
await logEpisodeWatch(userId, ep.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unwatchSeasonAction(seasonId: string) {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
await unwatchSeason(userId, seasonId);
|
||||||
|
}
|
||||||
@@ -9,6 +9,15 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Season } from "@/lib/types/title";
|
import type { Season } from "@/lib/types/title";
|
||||||
|
import {
|
||||||
|
unwatchEpisodeAction,
|
||||||
|
unwatchSeasonAction,
|
||||||
|
updateTitleRating,
|
||||||
|
updateTitleStatus,
|
||||||
|
watchEpisode,
|
||||||
|
watchMovie,
|
||||||
|
watchSeason,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
interface TitleInteractionState {
|
interface TitleInteractionState {
|
||||||
titleId: string;
|
titleId: string;
|
||||||
@@ -74,12 +83,7 @@ export function TitleInteractionProvider({
|
|||||||
const prev = userStatus;
|
const prev = userStatus;
|
||||||
setUserStatus(status);
|
setUserStatus(status);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/titles/${titleId}/status`, {
|
await updateTitleStatus(titleId, status);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ status }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const label =
|
const label =
|
||||||
status === "watchlist"
|
status === "watchlist"
|
||||||
? "Added to watchlist"
|
? "Added to watchlist"
|
||||||
@@ -102,12 +106,7 @@ export function TitleInteractionProvider({
|
|||||||
const prev = userRating;
|
const prev = userRating;
|
||||||
setUserRating(ratingStars);
|
setUserRating(ratingStars);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/titles/${titleId}/rating`, {
|
await updateTitleRating(titleId, ratingStars);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ratingStars }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(
|
toast.success(
|
||||||
ratingStars > 0
|
ratingStars > 0
|
||||||
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}`
|
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}`
|
||||||
@@ -125,10 +124,7 @@ export function TitleInteractionProvider({
|
|||||||
const prev = userStatus;
|
const prev = userStatus;
|
||||||
setUserStatus("completed");
|
setUserStatus("completed");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/movies/${titleId}/watch`, {
|
await watchMovie(titleId);
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(`Marked "${titleName}" as watched`);
|
toast.success(`Marked "${titleName}" as watched`);
|
||||||
} catch {
|
} catch {
|
||||||
setUserStatus(prev);
|
setUserStatus(prev);
|
||||||
@@ -148,10 +144,7 @@ export function TitleInteractionProvider({
|
|||||||
setEpisodeWatches((w) => w.filter((id) => id !== episodeId));
|
setEpisodeWatches((w) => w.filter((id) => id !== episodeId));
|
||||||
setUserStatus((s) => (s === "completed" ? "in_progress" : s));
|
setUserStatus((s) => (s === "completed" ? "in_progress" : s));
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/episodes/${episodeId}/watch`, {
|
await unwatchEpisodeAction(episodeId);
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(`Unwatched S${seasonNum} E${epNum}`);
|
toast.success(`Unwatched S${seasonNum} E${epNum}`);
|
||||||
} catch {
|
} catch {
|
||||||
setEpisodeWatches((w) =>
|
setEpisodeWatches((w) =>
|
||||||
@@ -165,10 +158,7 @@ export function TitleInteractionProvider({
|
|||||||
);
|
);
|
||||||
setUserStatus((s) => s ?? "in_progress");
|
setUserStatus((s) => s ?? "in_progress");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/episodes/${episodeId}/watch`, {
|
await watchEpisode(episodeId);
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(`Watched S${seasonNum} E${epNum}`);
|
toast.success(`Watched S${seasonNum} E${epNum}`);
|
||||||
} catch {
|
} catch {
|
||||||
setEpisodeWatches((w) => w.filter((id) => id !== episodeId));
|
setEpisodeWatches((w) => w.filter((id) => id !== episodeId));
|
||||||
@@ -194,10 +184,7 @@ export function TitleInteractionProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/seasons/${season.id}/watch`, {
|
await watchSeason(season.id);
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||||
);
|
);
|
||||||
@@ -215,10 +202,7 @@ export function TitleInteractionProvider({
|
|||||||
setUserStatus((s) => (s === "completed" ? "in_progress" : s));
|
setUserStatus((s) => (s === "completed" ? "in_progress" : s));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/seasons/${season.id}/watch`, {
|
await unwatchSeasonAction(season.id);
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/lib/auth/server";
|
|
||||||
import { logEpisodeWatch, unwatchEpisode } from "@/lib/services/tracking";
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
if (!session)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
await logEpisodeWatch(session.user.id, id);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
if (!session)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
await unwatchEpisode(session.user.id, id);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/lib/auth/server";
|
|
||||||
import { logMovieWatch } from "@/lib/services/tracking";
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
if (!session)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
await logMovieWatch(session.user.id, id);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/lib/auth/server";
|
|
||||||
import { db } from "@/lib/db/client";
|
|
||||||
import { episodes } from "@/lib/db/schema";
|
|
||||||
import { logEpisodeWatch, unwatchSeason } from "@/lib/services/tracking";
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
if (!session)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const seasonEps = await db
|
|
||||||
.select()
|
|
||||||
.from(episodes)
|
|
||||||
.where(eq(episodes.seasonId, id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const ep of seasonEps) {
|
|
||||||
await logEpisodeWatch(session.user.id, ep.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
if (!session)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
await unwatchSeason(session.user.id, id);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/lib/auth/server";
|
|
||||||
import { rateTitleStars } from "@/lib/services/tracking";
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
if (!session)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await req.json();
|
|
||||||
const { ratingStars } = body;
|
|
||||||
|
|
||||||
if (typeof ratingStars !== "number" || ratingStars < 0 || ratingStars > 5) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "ratingStars must be 0-5" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await rateTitleStars(session.user.id, id, ratingStars);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/lib/auth/server";
|
|
||||||
import {
|
|
||||||
getUserTitleInfo,
|
|
||||||
removeTitleStatus,
|
|
||||||
setTitleStatus,
|
|
||||||
} from "@/lib/services/tracking";
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
if (!session)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const info = await getUserTitleInfo(session.user.id, id);
|
|
||||||
return NextResponse.json(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: await headers(),
|
|
||||||
});
|
|
||||||
if (!session)
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await req.json();
|
|
||||||
const { status } = body;
|
|
||||||
|
|
||||||
if (status === null || status === undefined) {
|
|
||||||
await removeTitleStatus(session.user.id, id);
|
|
||||||
} else {
|
|
||||||
await setTitleStatus(session.user.id, id, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user