Add Docker packaging and migrate from better-sqlite3 to libsql

Docker self-hosting support:
- Dockerfile (multi-stage Alpine build with tini init)
- docker-compose.yml with named volume for SQLite persistence
- /api/health endpoint for container health checks
- Auto-migration on startup via drizzle-orm/libsql/migrator
- Graceful shutdown (SIGTERM stops scheduler, closes DB)
- Next.js standalone output mode for minimal image size

Database driver migration (better-sqlite3 → @libsql/client):
- Eliminates native C++ compilation, enabling Alpine Docker images
- All DB queries converted from sync to async across services and routes
- DATABASE_URL now uses libsql file: prefix format
- drizzle.config.ts dialect changed to turso for libsql support
- Initial migration files generated in drizzle/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:14:57 -05:00
co-authored by Claude Opus 4.6
parent 8f99a20a9c
commit 96213c3086
33 changed files with 3837 additions and 593 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules
.next
.git
.gitignore
*.md
*.db
*.db-*
.env
.env.*
!.env.example
.DS_Store
.vercel
coverage
.vscode
.idea
+8 -1
View File
@@ -1,4 +1,11 @@
DATABASE_URL=sqlite.db
# SQLite database URL (Docker: file:/data/sqlite.db, local dev: file:sqlite.db)
DATABASE_URL=file:sqlite.db
# TMDB API key — get one at https://www.themoviedb.org/settings/api
TMDB_API_KEY=your_tmdb_api_key_here
# Random secret for session encryption (min 32 chars)
BETTER_AUTH_SECRET=your_secret_here
# Public URL of your instance
BETTER_AUTH_URL=http://localhost:3000
+3 -2
View File
@@ -23,9 +23,10 @@
# misc
.DS_Store
*.pem
# sqlite
*.db
*.db-shm
*.db-wal
*.db-*
# debug
npm-debug.log*
+1 -1
View File
@@ -22,7 +22,7 @@ pnpm db:studio # Open Drizzle Studio (visual DB browser)
### Stack
- **Framework**: Next.js 16 (App Router), React 19, TypeScript
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, singleton via `globalThis`)
- **Database**: SQLite via @libsql/client + Drizzle ORM (WAL mode, singleton via `globalThis`, async queries)
- **Auth**: Better Auth with Drizzle adapter, email/password
- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono)
+41
View File
@@ -0,0 +1,41 @@
FROM node:24-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# --- Builder ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN pnpm build
# --- Runner ---
FROM node:24-alpine AS runner
RUN apk add --no-cache tini
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs \
&& mkdir -p /data && chown nextjs:nodejs /data
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
USER nextjs
EXPOSE 3000
VOLUME /data
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
+2 -2
View File
@@ -15,7 +15,7 @@ export async function POST(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
logEpisodeWatch(session.user.id, id);
await logEpisodeWatch(session.user.id, id);
return NextResponse.json({ ok: true });
}
@@ -30,6 +30,6 @@ export async function DELETE(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
unwatchEpisode(session.user.id, id);
await unwatchEpisode(session.user.id, id);
return NextResponse.json({ ok: true });
}
+1 -1
View File
@@ -10,6 +10,6 @@ export async function GET() {
if (!session)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const feed = getContinueWatchingFeed(session.user.id);
const feed = await getContinueWatchingFeed(session.user.id);
return NextResponse.json(feed);
}
+1 -1
View File
@@ -12,6 +12,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const days = Number(req.nextUrl.searchParams.get("days") ?? 14);
const feed = getNewAvailableFeed(session.user.id, days);
const feed = await getNewAvailableFeed(session.user.id, days);
return NextResponse.json(feed);
}
+1 -1
View File
@@ -10,6 +10,6 @@ export async function GET() {
if (!session)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const feed = getRecommendationsFeed(session.user.id);
const feed = await getRecommendationsFeed(session.user.id);
return NextResponse.json(feed);
}
+4 -4
View File
@@ -27,7 +27,7 @@ export async function GET() {
weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
weekStart.setHours(0, 0, 0, 0);
const [moviesThisMonth] = db
const [moviesThisMonth] = await db
.select({ count: sql<number>`count(*)` })
.from(userMovieWatches)
.where(
@@ -38,7 +38,7 @@ export async function GET() {
)
.all();
const [episodesThisWeek] = db
const [episodesThisWeek] = await db
.select({ count: sql<number>`count(*)` })
.from(userEpisodeWatches)
.where(
@@ -49,13 +49,13 @@ export async function GET() {
)
.all();
const [librarySize] = db
const [librarySize] = await db
.select({ count: sql<number>`count(*)` })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
const [completedCount] = db
const [completedCount] = await db
.select({ count: sql<number>`count(*)` })
.from(userTitleStatus)
.where(
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export function GET() {
return NextResponse.json({ status: "ok" });
}
+1 -1
View File
@@ -15,6 +15,6 @@ export async function POST(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
logMovieWatch(session.user.id, id);
await logMovieWatch(session.user.id, id);
return NextResponse.json({ ok: true });
}
+3 -3
View File
@@ -18,14 +18,14 @@ export async function POST(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const seasonEps = db
const seasonEps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, id))
.all();
for (const ep of seasonEps) {
logEpisodeWatch(session.user.id, ep.id);
await logEpisodeWatch(session.user.id, ep.id);
}
return NextResponse.json({ ok: true });
@@ -42,6 +42,6 @@ export async function DELETE(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
unwatchSeason(session.user.id, id);
await unwatchSeason(session.user.id, id);
return NextResponse.json({ ok: true });
}
+1 -1
View File
@@ -25,6 +25,6 @@ export async function POST(
);
}
rateTitleStars(session.user.id, id, ratingStars);
await rateTitleStars(session.user.id, id, ratingStars);
return NextResponse.json({ ok: true });
}
+16 -14
View File
@@ -9,11 +9,11 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const title = db.select().from(titles).where(eq(titles.id, id)).get();
const title = await db.select().from(titles).where(eq(titles.id, id)).get();
if (!title)
return NextResponse.json({ error: "Title not found" }, { status: 404 });
const recs = db
const recs = await db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
source: titleRecommendations.source,
@@ -24,18 +24,20 @@ export async function GET(
.orderBy(titleRecommendations.rank)
.all();
const results = recs
.map((rec) => {
const recTitle = db
.select()
.from(titles)
.where(eq(titles.id, rec.recommendedTitleId))
.get();
return recTitle
? { ...recTitle, source: rec.source, rank: rec.rank }
: null;
})
.filter(Boolean);
const results = (
await Promise.all(
recs.map(async (rec) => {
const recTitle = await db
.select()
.from(titles)
.where(eq(titles.id, rec.recommendedTitleId))
.get();
return recTitle
? { ...recTitle, source: rec.source, rank: rec.rank }
: null;
}),
)
).filter(Boolean);
return NextResponse.json(results);
}
+19 -14
View File
@@ -10,7 +10,7 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
let title = db.select().from(titles).where(eq(titles.id, id)).get();
let title = await db.select().from(titles).where(eq(titles.id, id)).get();
if (!title)
return NextResponse.json({ error: "Title not found" }, { status: 404 });
@@ -19,7 +19,8 @@ export async function GET(
if (title.type === "tv" && !title.lastFetchedAt) {
try {
const show = await getTvDetails(title.tmdbId);
db.update(titles)
await db
.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
@@ -30,7 +31,9 @@ export async function GET(
.where(eq(titles.id, id))
.run();
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
title =
(await db.select().from(titles).where(eq(titles.id, id)).get()) ??
title;
} catch {
// Continue with whatever data we have
}
@@ -55,25 +58,27 @@ export async function GET(
}> = [];
if (title.type === "tv") {
const seasonRows = db
const seasonRows = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, title.id))
.orderBy(seasons.seasonNumber)
.all();
titleSeasons = seasonRows.map((s) => ({
...s,
episodes: db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.orderBy(episodes.episodeNumber)
.all(),
}));
titleSeasons = await Promise.all(
seasonRows.map(async (s) => ({
...s,
episodes: await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.orderBy(episodes.episodeNumber)
.all(),
})),
);
}
const availability = db
const availability = await db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, title.id))
+3 -3
View File
@@ -19,7 +19,7 @@ export async function GET(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const info = getUserTitleInfo(session.user.id, id);
const info = await getUserTitleInfo(session.user.id, id);
return NextResponse.json(info);
}
@@ -38,9 +38,9 @@ export async function POST(
const { status } = body;
if (status === null || status === undefined) {
removeTitleStatus(session.user.id, id);
await removeTitleStatus(session.user.id, id);
} else {
setTitleStatus(session.user.id, id, status);
await setTitleStatus(session.user.id, id, status);
}
return NextResponse.json({ ok: true });
+1 -1
View File
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
"includes": ["**", "!node_modules", "!.next", "!dist", "!build", "!drizzle"]
},
"css": {
"parser": {
+24
View File
@@ -0,0 +1,24 @@
services:
couch-potato:
build: .
image: couch-potato
container_name: couch-potato
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- couch-potato-data:/data
environment:
- DATABASE_URL=file:/data/sqlite.db
- TMDB_API_KEY=${TMDB_API_KEY}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
start_period: 30s
retries: 3
volumes:
couch-potato-data:
+2 -2
View File
@@ -1,10 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
dialect: "turso",
schema: "./lib/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "sqlite.db",
url: process.env.DATABASE_URL ?? "file:sqlite.db",
},
});
@@ -0,0 +1,164 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`accountId` text NOT NULL,
`providerId` text NOT NULL,
`accessToken` text,
`refreshToken` text,
`accessTokenExpiresAt` integer,
`refreshTokenExpiresAt` integer,
`scope` text,
`password` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_account_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `availabilityOffers` (
`titleId` text NOT NULL,
`region` text DEFAULT 'US' NOT NULL,
`providerId` integer NOT NULL,
`providerName` text NOT NULL,
`logoPath` text,
`offerType` text NOT NULL,
`link` text,
`lastFetchedAt` integer,
CONSTRAINT `fk_availabilityOffers_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `episodes` (
`id` text PRIMARY KEY,
`seasonId` text NOT NULL,
`episodeNumber` integer NOT NULL,
`name` text,
`overview` text,
`stillPath` text,
`airDate` text,
`runtimeMinutes` integer,
CONSTRAINT `fk_episodes_seasonId_seasons_id_fk` FOREIGN KEY (`seasonId`) REFERENCES `seasons`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `seasons` (
`id` text PRIMARY KEY,
`titleId` text NOT NULL,
`seasonNumber` integer NOT NULL,
`name` text,
`overview` text,
`posterPath` text,
`airDate` text,
`lastFetchedAt` integer,
CONSTRAINT `fk_seasons_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`token` text NOT NULL UNIQUE,
`expiresAt` integer NOT NULL,
`ipAddress` text,
`userAgent` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_session_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `titleRecommendations` (
`titleId` text NOT NULL,
`recommendedTitleId` text NOT NULL,
`source` text NOT NULL,
`rank` integer NOT NULL,
`lastFetchedAt` integer,
CONSTRAINT `fk_titleRecommendations_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_titleRecommendations_recommendedTitleId_titles_id_fk` FOREIGN KEY (`recommendedTitleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `titles` (
`id` text PRIMARY KEY,
`tmdbId` integer NOT NULL,
`type` text NOT NULL,
`title` text NOT NULL,
`originalTitle` text,
`overview` text,
`releaseDate` text,
`firstAirDate` text,
`posterPath` text,
`backdropPath` text,
`popularity` real,
`voteAverage` real,
`voteCount` integer,
`status` text,
`lastFetchedAt` integer
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY,
`name` text NOT NULL,
`email` text NOT NULL UNIQUE,
`emailVerified` integer DEFAULT false NOT NULL,
`image` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `userEpisodeWatches` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`episodeId` text NOT NULL,
`watchedAt` integer NOT NULL,
`source` text DEFAULT 'manual' NOT NULL,
CONSTRAINT `fk_userEpisodeWatches_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userEpisodeWatches_episodeId_episodes_id_fk` FOREIGN KEY (`episodeId`) REFERENCES `episodes`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userMovieWatches` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`titleId` text NOT NULL,
`watchedAt` integer NOT NULL,
`source` text DEFAULT 'manual' NOT NULL,
CONSTRAINT `fk_userMovieWatches_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userMovieWatches_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userRatings` (
`userId` text NOT NULL,
`titleId` text NOT NULL,
`ratingStars` integer NOT NULL,
`ratedAt` integer NOT NULL,
CONSTRAINT `fk_userRatings_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userRatings_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userTitleStatus` (
`userId` text NOT NULL,
`titleId` text NOT NULL,
`status` text NOT NULL,
`addedAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_userTitleStatus_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userTitleStatus_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expiresAt` integer NOT NULL,
`createdAt` integer,
`updatedAt` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `availabilityOffers_unique` ON `availabilityOffers` (`titleId`,`region`,`providerId`,`offerType`);--> statement-breakpoint
CREATE UNIQUE INDEX `episodes_seasonId_episodeNumber` ON `episodes` (`seasonId`,`episodeNumber`);--> statement-breakpoint
CREATE UNIQUE INDEX `seasons_titleId_seasonNumber` ON `seasons` (`titleId`,`seasonNumber`);--> statement-breakpoint
CREATE UNIQUE INDEX `titleRecommendations_unique` ON `titleRecommendations` (`titleId`,`recommendedTitleId`,`source`);--> statement-breakpoint
CREATE UNIQUE INDEX `titles_tmdbId_unique` ON `titles` (`tmdbId`);--> statement-breakpoint
CREATE INDEX `titles_type_releaseDate` ON `titles` (`type`,`releaseDate`);--> statement-breakpoint
CREATE INDEX `titles_type_firstAirDate` ON `titles` (`type`,`firstAirDate`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_userId_watchedAt` ON `userEpisodeWatches` (`userId`,`watchedAt`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_episodeId` ON `userEpisodeWatches` (`episodeId`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_userId_watchedAt` ON `userMovieWatches` (`userId`,`watchedAt`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_titleId` ON `userMovieWatches` (`titleId`);--> statement-breakpoint
CREATE UNIQUE INDEX `userRatings_userId_titleId` ON `userRatings` (`userId`,`titleId`);--> statement-breakpoint
CREATE UNIQUE INDEX `userTitleStatus_userId_titleId` ON `userTitleStatus` (`userId`,`titleId`);--> statement-breakpoint
CREATE INDEX `userTitleStatus_userId_status` ON `userTitleStatus` (`userId`,`status`);
File diff suppressed because it is too large Load Diff
+25 -6
View File
@@ -3,11 +3,30 @@ export async function onRequestError() {
}
export async function register() {
if (
process.env.NEXT_RUNTIME === "nodejs" &&
process.env.NODE_ENV === "production"
) {
const { initJobs } = await import("@/lib/jobs/init");
initJobs();
if (process.env.NEXT_RUNTIME === "nodejs") {
// Run database migrations on startup
if (process.env.NODE_ENV === "production") {
const { runMigrations } = await import("@/lib/db/migrate");
await runMigrations();
const { initJobs } = await import("@/lib/jobs/init");
initJobs();
// Graceful shutdown — stop jobs and close DB on container stop
const { scheduler } = await import("@/lib/jobs/scheduler");
const { client } = await import("@/lib/db/client");
const shutdown = () => {
console.log("[shutdown] Stopping scheduler...");
scheduler.stop();
console.log("[shutdown] Closing database...");
client.close();
console.log("[shutdown] Clean shutdown complete");
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
}
}
+12 -10
View File
@@ -1,22 +1,24 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";
const globalForDb = globalThis as unknown as {
_db: ReturnType<typeof drizzle> | undefined;
_sqlite: Database.Database | undefined;
_client: ReturnType<typeof createClient> | undefined;
};
if (!globalForDb._sqlite) {
globalForDb._sqlite = new Database(process.env.DATABASE_URL ?? "sqlite.db");
globalForDb._sqlite.pragma("journal_mode = WAL");
globalForDb._sqlite.pragma("foreign_keys = ON");
globalForDb._sqlite.pragma("busy_timeout = 5000");
if (!globalForDb._client) {
globalForDb._client = createClient({
url: process.env.DATABASE_URL ?? "file:sqlite.db",
});
globalForDb._client.execute("PRAGMA journal_mode = WAL");
globalForDb._client.execute("PRAGMA foreign_keys = ON");
globalForDb._client.execute("PRAGMA busy_timeout = 5000");
}
if (!globalForDb._db) {
globalForDb._db = drizzle(globalForDb._sqlite, { schema });
globalForDb._db = drizzle({ client: globalForDb._client, schema });
}
export const db = globalForDb._db;
export const sqlite = globalForDb._sqlite;
export const client = globalForDb._client;
+8
View File
@@ -0,0 +1,8 @@
import { migrate } from "drizzle-orm/libsql/migrator";
import { db } from "./client";
export async function runMigrations() {
console.log("[migrate] Running database migrations...");
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("[migrate] Database migrations complete");
}
+13 -13
View File
@@ -23,24 +23,24 @@ function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function getLibraryTitleIds(): string[] {
return db
async function getLibraryTitleIds(): Promise<string[]> {
const rows = await db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.groupBy(userTitleStatus.titleId)
.all()
.map((r) => r.titleId);
.all();
return rows.map((r) => r.titleId);
}
// Refresh titles where lastFetchedAt is stale
async function nightlyRefreshLibrary() {
const libraryIds = getLibraryTitleIds();
const libraryIds = await getLibraryTitleIds();
const libraryStale = new Date(Date.now() - 7 * DAY);
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
// Library titles: 7 days
for (const titleId of libraryIds) {
const t = db
const t = await db
.select()
.from(titles)
.where(
@@ -54,7 +54,7 @@ async function nightlyRefreshLibrary() {
}
// Non-library titles: 30 days
const nonLibrary = db
const nonLibrary = await db
.select()
.from(titles)
.where(
@@ -76,12 +76,12 @@ async function nightlyRefreshLibrary() {
// Refresh availability for library titles where stale
async function refreshAvailabilityJob() {
const libraryIds = getLibraryTitleIds();
const libraryIds = await getLibraryTitleIds();
const stale = new Date(Date.now() - DAY);
for (const titleId of libraryIds) {
// Check if any offer is stale
const offer = db
const offer = await db
.select()
.from(availabilityOffers)
.where(
@@ -93,7 +93,7 @@ async function refreshAvailabilityJob() {
.get();
// Also handle titles with no offers yet
const anyOffer = db
const anyOffer = await db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
@@ -108,7 +108,7 @@ async function refreshAvailabilityJob() {
// Refresh recommendations for recently active titles
async function refreshRecommendationsJob() {
const libraryIds = getLibraryTitleIds();
const libraryIds = await getLibraryTitleIds();
for (const titleId of libraryIds) {
await refreshRecommendations(titleId);
@@ -121,7 +121,7 @@ async function refreshTvChildrenJob() {
const returningStatuses = ["Returning Series", "In Production"];
const stale = new Date(Date.now() - 7 * DAY);
const tvShows = db
const tvShows = await db
.select()
.from(titles)
.where(
@@ -135,7 +135,7 @@ async function refreshTvChildrenJob() {
for (const show of tvShows) {
// Check if seasons are stale
const staleSeason = db
const staleSeason = await db
.select()
.from(seasons)
.where(
+10 -4
View File
@@ -4,7 +4,11 @@ import { availabilityOffers, titles } from "@/lib/db/schema";
import { getWatchProviders } from "@/lib/tmdb/client";
export async function refreshAvailability(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title) return;
const data = await getWatchProviders(title.tmdbId, title.type);
@@ -15,7 +19,8 @@ export async function refreshAvailability(titleId: string) {
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
// Delete existing offers for this title+region
db.delete(availabilityOffers)
await db
.delete(availabilityOffers)
.where(
and(
eq(availabilityOffers.titleId, titleId),
@@ -29,7 +34,8 @@ export async function refreshAvailability(titleId: string) {
if (!providers) continue;
for (const p of providers) {
db.insert(availabilityOffers)
await db
.insert(availabilityOffers)
.values({
titleId,
region: "US",
@@ -46,7 +52,7 @@ export async function refreshAvailability(titleId: string) {
}
}
export function getAvailability(titleId: string) {
export async function getAvailability(titleId: string) {
return db
.select()
.from(availabilityOffers)
+54 -46
View File
@@ -32,11 +32,11 @@ export interface ContinueWatchingItem {
watchedEpisodes: number;
}
export function getContinueWatchingFeed(
export async function getContinueWatchingFeed(
userId: string,
): ContinueWatchingItem[] {
): Promise<ContinueWatchingItem[]> {
// Get in-progress TV shows
const inProgress = db
const inProgress = await db
.select({
titleId: userTitleStatus.titleId,
updatedAt: userTitleStatus.updatedAt,
@@ -54,7 +54,7 @@ export function getContinueWatchingFeed(
const today = new Date().toISOString().slice(0, 10);
for (const row of inProgress) {
const title = db
const title = await db
.select()
.from(titles)
.where(and(eq(titles.id, row.titleId), eq(titles.type, "tv")))
@@ -62,7 +62,7 @@ export function getContinueWatchingFeed(
if (!title) continue;
// Get all seasons for this title, ordered
const titleSeasons = db
const titleSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, title.id))
@@ -77,7 +77,7 @@ export function getContinueWatchingFeed(
// Get most recent watch for this show
for (const s of titleSeasons) {
const eps = db
const eps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
@@ -87,7 +87,7 @@ export function getContinueWatchingFeed(
totalEpisodes += eps.length;
for (const ep of eps) {
const watch = db
const watch = await db
.select()
.from(userEpisodeWatches)
.where(
@@ -146,10 +146,10 @@ export function getContinueWatchingFeed(
}
// biome-ignore lint/correctness/noUnusedFunctionParameters: days reserved for future date filtering
export function getNewAvailableFeed(userId: string, days = 14) {
export async function getNewAvailableFeed(userId: string, days = 14) {
// Get titles the user has in any status that have availability offers
// and recent release/air dates
const results = db
const results = await db
.select({
titleId: titles.id,
title: titles.title,
@@ -177,46 +177,52 @@ export function getNewAvailableFeed(userId: string, days = 14) {
return results;
}
export function getRecommendationsFeed(userId: string) {
export async function getRecommendationsFeed(userId: string) {
// Get recommendations from user's highly-rated or completed titles
const userCompletedOrRated = db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.status, "completed"),
),
)
.all()
.map((r) => r.titleId);
const userCompletedOrRated = (
await db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.status, "completed"),
),
)
.all()
).map((r) => r.titleId);
const ratedIds = db
.select({ titleId: userRatings.titleId })
.from(userRatings)
.where(
and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`),
)
.all()
.map((r) => r.titleId);
const ratedIds = (
await db
.select({ titleId: userRatings.titleId })
.from(userRatings)
.where(
and(
eq(userRatings.userId, userId),
sql`${userRatings.ratingStars} >= 4`,
),
)
.all()
).map((r) => r.titleId);
const sourceIds = [...new Set([...userCompletedOrRated, ...ratedIds])];
if (sourceIds.length === 0) return [];
// Get all tracked title IDs to exclude
const trackedIds = new Set(
db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all()
.map((r) => r.titleId),
(
await db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all()
).map((r) => r.titleId),
);
const recs: Map<string, { titleId: string; score: number }> = new Map();
for (const sourceId of sourceIds) {
const recRows = db
const recRows = await db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
rank: titleRecommendations.rank,
@@ -244,14 +250,16 @@ export function getRecommendationsFeed(userId: string) {
.sort((a, b) => b.score - a.score)
.slice(0, 20);
return sorted
.map((r) => {
const title = db
.select()
.from(titles)
.where(eq(titles.id, r.titleId))
.get();
return title;
})
.filter(Boolean);
return (
await Promise.all(
sorted.map(async (r) => {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, r.titleId))
.get();
return title;
}),
)
).filter(Boolean);
}
+39 -23
View File
@@ -16,7 +16,7 @@ import {
import { refreshAvailability } from "./availability";
export async function importTitle(tmdbId: number, type: "movie" | "tv") {
const existing = db
const existing = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, tmdbId))
@@ -26,15 +26,18 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
// They may be missing if a prior fetch failed or the title was created
// as a shell by the recommendations system (lastFetchedAt: null).
if (existing.type === "tv") {
const seasonCount = db
.select()
.from(seasons)
.where(eq(seasons.titleId, existing.id))
.all().length;
const seasonCount = (
await db
.select()
.from(seasons)
.where(eq(seasons.titleId, existing.id))
.all()
).length;
if (seasonCount === 0) {
const show = await getTvDetails(tmdbId);
if (!existing.lastFetchedAt) {
db.update(titles)
await db
.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
@@ -58,7 +61,7 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
if (type === "movie") {
const movie = await getMovieDetails(tmdbId);
const row = db
const row = await db
.insert(titles)
.values({
tmdbId: movie.id,
@@ -84,7 +87,7 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
}
const show = await getTvDetails(tmdbId);
const row = db
const row = await db
.insert(titles)
.values({
tmdbId: show.id,
@@ -112,14 +115,19 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
}
export async function refreshTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title) return null;
const now = new Date();
if (title.type === "movie") {
const movie = await getMovieDetails(title.tmdbId);
db.update(titles)
await db
.update(titles)
.set({
title: movie.title,
originalTitle: movie.original_title,
@@ -137,7 +145,8 @@ export async function refreshTitle(titleId: string) {
.run();
} else {
const show = await getTvDetails(title.tmdbId);
db.update(titles)
await db
.update(titles)
.set({
title: show.name,
originalTitle: show.original_name,
@@ -173,7 +182,7 @@ export async function refreshTvChildren(
try {
const seasonData = await getTvSeasonDetails(tmdbId, sn);
const seasonRow = db
const seasonRow = await db
.insert(seasons)
.values({
titleId,
@@ -198,7 +207,8 @@ export async function refreshTvChildren(
.get();
for (const ep of seasonData.episodes) {
db.insert(episodes)
await db
.insert(episodes)
.values({
seasonId: seasonRow.id,
episodeNumber: ep.episode_number,
@@ -229,7 +239,11 @@ export async function refreshTvChildren(
}
export async function refreshRecommendations(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title) return;
const now = new Date();
@@ -247,7 +261,7 @@ export async function refreshRecommendations(titleId: string) {
if (type !== "movie" && type !== "tv") continue;
// Minimal upsert of the recommended title
const existing = db
const existing = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
@@ -256,7 +270,7 @@ export async function refreshRecommendations(titleId: string) {
if (existing) {
recTitleId = existing.id;
} else {
const row = db
const row = await db
.insert(titles)
.values({
tmdbId: r.id,
@@ -277,7 +291,7 @@ export async function refreshRecommendations(titleId: string) {
.returning()
.get();
if (!row) {
const found = db
const found = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
@@ -289,7 +303,8 @@ export async function refreshRecommendations(titleId: string) {
}
}
db.insert(titleRecommendations)
await db
.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId: recTitleId,
@@ -314,7 +329,7 @@ export async function refreshRecommendations(titleId: string) {
const type = r.media_type ?? title.type;
if (type !== "movie" && type !== "tv") continue;
const existing = db
const existing = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
@@ -323,7 +338,7 @@ export async function refreshRecommendations(titleId: string) {
if (existing) {
recTitleId = existing.id;
} else {
const row = db
const row = await db
.insert(titles)
.values({
tmdbId: r.id,
@@ -344,7 +359,7 @@ export async function refreshRecommendations(titleId: string) {
.returning()
.get();
if (!row) {
const found = db
const found = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
@@ -356,7 +371,8 @@ export async function refreshRecommendations(titleId: string) {
}
}
db.insert(titleRecommendations)
await db
.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId: recTitleId,
+70 -49
View File
@@ -10,13 +10,14 @@ import {
userTitleStatus,
} from "@/lib/db/schema";
export function setTitleStatus(
export async function setTitleStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
) {
const now = new Date();
db.insert(userTitleStatus)
await db
.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
@@ -25,12 +26,13 @@ export function setTitleStatus(
.run();
if (status === "completed") {
markAllEpisodesWatched(userId, titleId);
await markAllEpisodesWatched(userId, titleId);
}
}
export function removeTitleStatus(userId: string, titleId: string) {
db.delete(userTitleStatus)
export async function removeTitleStatus(userId: string, titleId: string) {
await db
.delete(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
@@ -40,14 +42,15 @@ export function removeTitleStatus(userId: string, titleId: string) {
.run();
}
export function logMovieWatch(userId: string, titleId: string) {
export async function logMovieWatch(userId: string, titleId: string) {
const now = new Date();
db.insert(userMovieWatches)
await db
.insert(userMovieWatches)
.values({ userId, titleId, watchedAt: now, source: "manual" })
.run();
// Auto-set status to completed
const existing = db
const existing = await db
.select()
.from(userTitleStatus)
.where(
@@ -59,22 +62,27 @@ export function logMovieWatch(userId: string, titleId: string) {
.get();
if (!existing) {
setTitleStatus(userId, titleId, "completed");
await setTitleStatus(userId, titleId, "completed");
} else if (existing.status !== "completed") {
setTitleStatus(userId, titleId, "completed");
await setTitleStatus(userId, titleId, "completed");
}
}
export function logEpisodeWatch(userId: string, episodeId: string) {
export async function logEpisodeWatch(userId: string, episodeId: string) {
const now = new Date();
db.insert(userEpisodeWatches)
await db
.insert(userEpisodeWatches)
.values({ userId, episodeId, watchedAt: now, source: "manual" })
.run();
// Find the title for this episode
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
const ep = await db
.select()
.from(episodes)
.where(eq(episodes.id, episodeId))
.get();
if (!ep) return;
const season = db
const season = await db
.select()
.from(seasons)
.where(eq(seasons.id, ep.seasonId))
@@ -83,7 +91,7 @@ export function logEpisodeWatch(userId: string, episodeId: string) {
const titleId = season.titleId;
// Auto-set status to in_progress if not set
const existing = db
const existing = await db
.select()
.from(userTitleStatus)
.where(
@@ -95,33 +103,37 @@ export function logEpisodeWatch(userId: string, episodeId: string) {
.get();
if (!existing) {
setTitleStatus(userId, titleId, "in_progress");
await setTitleStatus(userId, titleId, "in_progress");
}
// Check if all episodes are watched -> auto-complete
checkAllEpisodesWatched(userId, titleId);
await checkAllEpisodesWatched(userId, titleId);
}
function markAllEpisodesWatched(userId: string, titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
async function markAllEpisodesWatched(userId: string, titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title || title.type !== "tv") return;
const now = new Date();
const allSeasons = db
const allSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
const eps = db
const eps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const existing = db
const existing = await db
.select()
.from(userEpisodeWatches)
.where(
@@ -132,7 +144,8 @@ function markAllEpisodesWatched(userId: string, titleId: string) {
)
.get();
if (!existing) {
db.insert(userEpisodeWatches)
await db
.insert(userEpisodeWatches)
.values({
userId,
episodeId: ep.id,
@@ -145,8 +158,8 @@ function markAllEpisodesWatched(userId: string, titleId: string) {
}
}
function checkAllEpisodesWatched(userId: string, titleId: string) {
const allSeasons = db
async function checkAllEpisodesWatched(userId: string, titleId: string) {
const allSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
@@ -156,7 +169,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
let watchedEpisodes = 0;
for (const s of allSeasons) {
const eps = db
const eps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
@@ -164,7 +177,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
totalEpisodes += eps.length;
for (const ep of eps) {
const watch = db
const watch = await db
.select()
.from(userEpisodeWatches)
.where(
@@ -179,12 +192,13 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
}
if (totalEpisodes > 0 && watchedEpisodes >= totalEpisodes) {
setTitleStatus(userId, titleId, "completed");
await setTitleStatus(userId, titleId, "completed");
}
}
export function unwatchEpisode(userId: string, episodeId: string) {
db.delete(userEpisodeWatches)
export async function unwatchEpisode(userId: string, episodeId: string) {
await db
.delete(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
@@ -194,16 +208,20 @@ export function unwatchEpisode(userId: string, episodeId: string) {
.run();
// Find parent title and downgrade from completed to in_progress
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
const ep = await db
.select()
.from(episodes)
.where(eq(episodes.id, episodeId))
.get();
if (!ep) return;
const season = db
const season = await db
.select()
.from(seasons)
.where(eq(seasons.id, ep.seasonId))
.get();
if (!season) return;
const existing = db
const existing = await db
.select()
.from(userTitleStatus)
.where(
@@ -215,12 +233,12 @@ export function unwatchEpisode(userId: string, episodeId: string) {
.get();
if (existing?.status === "completed") {
setTitleStatus(userId, season.titleId, "in_progress");
await setTitleStatus(userId, season.titleId, "in_progress");
}
}
export function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = db
export async function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, seasonId))
@@ -228,7 +246,8 @@ export function unwatchSeason(userId: string, seasonId: string) {
const epIds = seasonEps.map((ep) => ep.id);
if (epIds.length > 0) {
db.delete(userEpisodeWatches)
await db
.delete(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
@@ -239,14 +258,14 @@ export function unwatchSeason(userId: string, seasonId: string) {
}
// Find parent title and downgrade from completed to in_progress
const season = db
const season = await db
.select()
.from(seasons)
.where(eq(seasons.id, seasonId))
.get();
if (!season) return;
const existing = db
const existing = await db
.select()
.from(userTitleStatus)
.where(
@@ -258,25 +277,27 @@ export function unwatchSeason(userId: string, seasonId: string) {
.get();
if (existing?.status === "completed") {
setTitleStatus(userId, season.titleId, "in_progress");
await setTitleStatus(userId, season.titleId, "in_progress");
}
}
export function rateTitleStars(
export async function rateTitleStars(
userId: string,
titleId: string,
ratingStars: number,
) {
const now = new Date();
if (ratingStars === 0) {
db.delete(userRatings)
await db
.delete(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.run();
return;
}
db.insert(userRatings)
await db
.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: now })
.onConflictDoUpdate({
target: [userRatings.userId, userRatings.titleId],
@@ -285,8 +306,8 @@ export function rateTitleStars(
.run();
}
export function getUserTitleInfo(userId: string, titleId: string) {
const status = db
export async function getUserTitleInfo(userId: string, titleId: string) {
const status = await db
.select()
.from(userTitleStatus)
.where(
@@ -297,7 +318,7 @@ export function getUserTitleInfo(userId: string, titleId: string) {
)
.get();
const rating = db
const rating = await db
.select()
.from(userRatings)
.where(
@@ -306,7 +327,7 @@ export function getUserTitleInfo(userId: string, titleId: string) {
.get();
// Get watched episode IDs for this title
const titleSeasons = db
const titleSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
@@ -314,13 +335,13 @@ export function getUserTitleInfo(userId: string, titleId: string) {
const watchedEpisodeIds: string[] = [];
for (const s of titleSeasons) {
const eps = db
const eps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const watch = db
const watch = await db
.select()
.from(userEpisodeWatches)
.where(
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
reactCompiler: true,
images: {
remotePatterns: [
+9 -8
View File
@@ -8,6 +8,7 @@
"start": "next start",
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
@@ -15,14 +16,14 @@
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@libsql/client": "^0.17.0",
"@tabler/icons-react": "^3.37.1",
"better-auth": "^1.4.19",
"better-sqlite3": "^12.6.2",
"better-auth": "^1.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "1.0.0-beta.15-859cf75",
"embla-carousel-react": "^8.6.0",
"motion": "^12.34.3",
"next": "16.1.6",
@@ -30,24 +31,24 @@
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^5.2.4",
"react-resizable-panels": "^4.6.5",
"react-resizable-panels": "^4.7.0",
"recharts": "2.15.4",
"shadcn": "^3.8.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"uuid": "^13.0.0",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.4",
"@tailwindcss/postcss": "^4.2.1",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.3.2",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "^0.31.9",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
}
+1681 -382
View File
File diff suppressed because it is too large Load Diff