BREAKING: postgres + saas

This commit is contained in:
Vasily Zubarev
2025-04-03 13:07:54 +02:00
parent 54a892ddb0
commit f523b1f8ba
136 changed files with 3971 additions and 1563 deletions

View File

@@ -0,0 +1,232 @@
"use server"
import {
categoryFormSchema,
currencyFormSchema,
fieldFormSchema,
projectFormSchema,
settingsFormSchema,
} from "@/forms/settings"
import { userFormSchema } from "@/forms/users"
import { getCurrentUser } from "@/lib/auth"
import { codeFromName, randomHexColor } from "@/lib/utils"
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
import { createField, deleteField, updateField } from "@/models/fields"
import { createProject, deleteProject, updateProject } from "@/models/projects"
import { updateSettings } from "@/models/settings"
import { updateUser } from "@/models/users"
import { Prisma } from "@prisma/client"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export async function saveSettingsAction(prevState: any, formData: FormData) {
const user = await getCurrentUser()
const validatedForm = settingsFormSchema.safeParse(Object.fromEntries(formData))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
for (const key in validatedForm.data) {
await updateSettings(user.id, key, validatedForm.data[key as keyof typeof validatedForm.data])
}
revalidatePath("/settings")
redirect("/settings")
// return { success: true }
}
export async function saveProfileAction(prevState: any, formData: FormData) {
const user = await getCurrentUser()
const validatedForm = userFormSchema.safeParse(Object.fromEntries(formData))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
await updateUser(user.id, {
name: validatedForm.data.name,
})
revalidatePath("/settings/profile")
redirect("/settings/profile")
}
export async function addProjectAction(userId: string, data: Prisma.ProjectCreateInput) {
const validatedForm = projectFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const project = await createProject(userId, {
code: codeFromName(validatedForm.data.name),
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt || null,
color: validatedForm.data.color || randomHexColor(),
})
revalidatePath("/settings/projects")
return { success: true, project }
}
export async function editProjectAction(userId: string, code: string, data: Prisma.ProjectUpdateInput) {
const validatedForm = projectFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const project = await updateProject(userId, code, {
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/projects")
return { success: true, project }
}
export async function deleteProjectAction(userId: string, code: string) {
try {
await deleteProject(userId, code)
} catch (error) {
return { success: false, error: "Failed to delete project" + error }
}
revalidatePath("/settings/projects")
return { success: true }
}
export async function addCurrencyAction(userId: string, data: Prisma.CurrencyCreateInput) {
const validatedForm = currencyFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const currency = await createCurrency(userId, {
code: validatedForm.data.code,
name: validatedForm.data.name,
})
revalidatePath("/settings/currencies")
return { success: true, currency }
}
export async function editCurrencyAction(userId: string, code: string, data: Prisma.CurrencyUpdateInput) {
const validatedForm = currencyFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const currency = await updateCurrency(userId, code, { name: validatedForm.data.name })
revalidatePath("/settings/currencies")
return { success: true, currency }
}
export async function deleteCurrencyAction(userId: string, code: string) {
try {
await deleteCurrency(userId, code)
} catch (error) {
return { success: false, error: "Failed to delete currency" + error }
}
revalidatePath("/settings/currencies")
return { success: true }
}
export async function addCategoryAction(userId: string, data: Prisma.CategoryCreateInput) {
const validatedForm = categoryFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const category = await createCategory(userId, {
code: codeFromName(validatedForm.data.name),
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/categories")
return { success: true, category }
}
export async function editCategoryAction(userId: string, code: string, data: Prisma.CategoryUpdateInput) {
const validatedForm = categoryFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const category = await updateCategory(userId, code, {
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/categories")
return { success: true, category }
}
export async function deleteCategoryAction(code: string, userId: string) {
try {
await deleteCategory(userId, code)
} catch (error) {
return { success: false, error: "Failed to delete category" + error }
}
revalidatePath("/settings/categories")
return { success: true }
}
export async function addFieldAction(userId: string, data: Prisma.FieldCreateInput) {
const validatedForm = fieldFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const field = await createField(userId, {
code: codeFromName(validatedForm.data.name),
name: validatedForm.data.name,
type: validatedForm.data.type,
llm_prompt: validatedForm.data.llm_prompt,
isVisibleInList: validatedForm.data.isVisibleInList,
isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis,
isExtra: true,
})
revalidatePath("/settings/fields")
return { success: true, field }
}
export async function editFieldAction(userId: string, code: string, data: Prisma.FieldUpdateInput) {
const validatedForm = fieldFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const field = await updateField(userId, code, {
name: validatedForm.data.name,
type: validatedForm.data.type,
llm_prompt: validatedForm.data.llm_prompt,
isVisibleInList: validatedForm.data.isVisibleInList,
isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis,
})
revalidatePath("/settings/fields")
return { success: true, field }
}
export async function deleteFieldAction(userId: string, code: string) {
try {
await deleteField(userId, code)
} catch (error) {
return { success: false, error: "Failed to delete field" + error }
}
revalidatePath("/settings/fields")
return { success: true }
}

View File

@@ -0,0 +1,145 @@
"use server"
import { getCurrentUser } from "@/lib/auth"
import { prisma } from "@/lib/db"
import { getUserUploadsDirectory } from "@/lib/files"
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
import fs from "fs/promises"
import JSZip from "jszip"
import path from "path"
const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
const REMOVE_EXISTING_DATA = true
export async function restoreBackupAction(prevState: any, formData: FormData) {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
const file = formData.get("file") as File
if (!file || file.size === 0) {
return { success: false, error: "No file provided" }
}
// Read zip archive
let zip: JSZip
try {
const fileBuffer = await file.arrayBuffer()
const fileData = Buffer.from(fileBuffer)
zip = await JSZip.loadAsync(fileData)
} catch (error) {
return { success: false, error: "Bad zip archive" }
}
if (REMOVE_EXISTING_DATA) {
await cleanupUserTables(user.id)
await fs.rm(userUploadsDirectory, { recursive: true, force: true })
}
// Check metadata and start restoring
try {
const metadataFile = zip.file("data/metadata.json")
if (metadataFile) {
const metadataContent = await metadataFile.async("string")
try {
const metadata = JSON.parse(metadataContent)
if (!metadata.version || !SUPPORTED_BACKUP_VERSIONS.includes(metadata.version)) {
return {
success: false,
error: `Incompatible backup version: ${
metadata.version || "unknown"
}. Supported versions: ${SUPPORTED_BACKUP_VERSIONS.join(", ")}`,
}
}
console.log(`Restoring backup version ${metadata.version} created at ${metadata.timestamp}`)
} catch (error) {
console.warn("Could not parse backup metadata:", error)
}
} else {
console.warn("No metadata found in backup, assuming legacy format")
}
const counters: Record<string, number> = {}
// Restore tables
for (const backup of MODEL_BACKUP) {
try {
const jsonFile = zip.file(`data/${backup.filename}`)
if (jsonFile) {
const jsonContent = await jsonFile.async("string")
const restoredCount = await modelFromJSON(user.id, backup, jsonContent)
console.log(`Restored ${restoredCount} records from ${backup.filename}`)
counters[backup.filename] = restoredCount
}
} catch (error) {
console.error(`Error restoring model from ${backup.filename}:`, error)
}
}
// Restore files
try {
let restoredFilesCount = 0
const files = await prisma.file.findMany({
where: {
userId: user.id,
},
})
const userUploadsDirectory = await getUserUploadsDirectory(user)
for (const file of files) {
const filePathWithoutPrefix = file.path.replace(/^.*\/uploads\//, "")
const zipFilePath = path.join("data/uploads", filePathWithoutPrefix)
const zipFile = zip.file(zipFilePath)
if (!zipFile) {
console.log(`File ${file.path} not found in backup`)
continue
}
const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
const fileContent = await zipFile.async("nodebuffer")
try {
await fs.mkdir(path.dirname(fullFilePath), { recursive: true })
await fs.writeFile(fullFilePath, fileContent)
restoredFilesCount++
} catch (error) {
console.error(`Error writing file ${fullFilePath}:`, error)
continue
}
await prisma.file.update({
where: { id: file.id },
data: {
path: filePathWithoutPrefix,
},
})
}
counters["Uploaded attachments"] = restoredFilesCount
} catch (error) {
console.error("Error restoring uploaded files:", error)
return {
success: false,
error: `Error restoring uploaded files: ${error instanceof Error ? error.message : String(error)}`,
}
}
return { success: true, message: "Restore completed successfully", counters }
} catch (error) {
console.error("Error restoring from backup:", error)
return {
success: false,
error: `Error restoring from backup: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
async function cleanupUserTables(userId: string) {
// Delete in reverse order to handle foreign key constraints
for (const { model } of [...MODEL_BACKUP].reverse()) {
try {
await model.deleteMany({ where: { userId } })
} catch (error) {
console.error(`Error clearing table:`, error)
}
}
}

View File

@@ -0,0 +1,112 @@
import { getCurrentUser } from "@/lib/auth"
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
import fs from "fs/promises"
import JSZip from "jszip"
import { NextResponse } from "next/server"
import path from "path"
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
const BACKUP_VERSION = "1.0"
export async function GET(request: Request) {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
try {
const zip = new JSZip()
const rootFolder = zip.folder("data")
if (!rootFolder) {
console.error("Failed to create zip folder")
return new NextResponse("Internal Server Error", { status: 500 })
}
// Add metadata with version information
rootFolder.file(
"metadata.json",
JSON.stringify(
{
version: BACKUP_VERSION,
timestamp: new Date().toISOString(),
models: MODEL_BACKUP.map((m) => m.filename),
},
null,
2
)
)
// Backup models
for (const backup of MODEL_BACKUP) {
try {
const jsonContent = await modelToJSON(user.id, backup)
rootFolder.file(backup.filename, jsonContent)
} catch (error) {
console.error(`Error exporting table ${backup.filename}:`, error)
}
}
const uploadsFolder = rootFolder.folder("uploads")
if (!uploadsFolder) {
console.error("Failed to create uploads folder")
return new NextResponse("Internal Server Error", { status: 500 })
}
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
for (const file of uploadedFiles) {
try {
// Check file size before reading
const stats = await fs.stat(file)
if (stats.size > MAX_FILE_SIZE) {
console.warn(
`Skipping large file ${file} (${Math.round(stats.size / 1024 / 1024)}MB > ${
MAX_FILE_SIZE / 1024 / 1024
}MB limit)`
)
return
}
const fileContent = await fs.readFile(file)
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
} catch (error) {
console.error(`Error reading file ${file}:`, error)
}
}
const archive = await zip.generateAsync({ type: "blob" })
return new NextResponse(archive, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="data.zip"`,
},
})
} catch (error) {
console.error("Error exporting database:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}
async function getAllFilePaths(dirPath: string): Promise<string[]> {
let filePaths: string[] = []
async function readDirectoryRecursively(currentPath: string) {
const isDirExists = await fileExists(currentPath)
if (!isDirExists) {
return
}
const entries = await fs.readdir(currentPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name)
if (entry.isDirectory()) {
await readDirectoryRecursively(fullPath)
} else {
filePaths.push(fullPath)
}
}
}
await readDirectoryRecursively(dirPath)
return filePaths
}

View File

@@ -0,0 +1,72 @@
"use client"
import { FormError } from "@/components/forms/error"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Download, Loader2 } from "lucide-react"
import Link from "next/link"
import { useActionState } from "react"
import { restoreBackupAction } from "./actions"
export default function BackupSettingsPage() {
const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null)
return (
<div className="container flex flex-col gap-4">
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Download backup</h1>
<div className="flex flex-row gap-4">
<Link href="/settings/backups/data">
<Button>
<Download /> Download Data Archive
</Button>
</Link>
</div>
<div className="text-sm text-muted-foreground max-w-xl">
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories,
projects, fields, currencies, and settings. You can view, edit or migrate your data to another service.
</div>
</div>
<Card className="flex flex-col gap-2 mt-16 p-5 bg-red-100 max-w-xl">
<h2 className="text-xl font-semibold">Restore from a backup</h2>
<p className="text-sm text-muted-foreground">
This action will delete all existing data from your current database and remove all uploaded files. Be
careful and make a backup first!
</p>
<form action={restoreBackup}>
<div className="flex flex-col gap-4 pt-4">
<input type="hidden" name="removeExistingData" value="true" />
<label>
<input type="file" name="file" />
</label>
<Button type="submit" variant="destructive" disabled={restorePending}>
{restorePending ? (
<>
<Loader2 className="animate-spin" /> Restoring from backup...
</>
) : (
"Delete existing data and restore from backup"
)}
</Button>
</div>
</form>
{restoreState?.error && <FormError>{restoreState.error}</FormError>}
</Card>
{restoreState?.success && (
<Card className="flex flex-col gap-2 p-5 bg-green-100 max-w-xl">
<h2 className="text-xl font-semibold">Backup restored successfully</h2>
<p className="text-sm text-muted-foreground">You can now continue using the app. Import stats:</p>
<ul className="list-disc list-inside">
{Object.entries(restoreState.counters || {}).map(([key, value]) => (
<li key={key}>
<span className="font-bold">{key}</span>: {value} items
</li>
))}
</ul>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/(app)/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrentUser } from "@/lib/auth"
import { randomHexColor } from "@/lib/utils"
import { getCategories } from "@/models/categories"
import { Prisma } from "@prisma/client"
export default async function CategoriesSettingsPage() {
const user = await getCurrentUser()
const categories = await getCategories(user.id)
const categoriesWithActions = categories.map((category) => ({
...category,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-2">Categories</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
Create your own categories that better reflect the type of income and expenses you have. Define an LLM Prompt so
that AI can determine this category automatically.
</p>
<CrudTable
items={categoriesWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{ key: "color", label: "Color", defaultValue: randomHexColor(), editable: true },
]}
onDelete={async (code) => {
"use server"
return await deleteCategoryAction(user.id, code)
}}
onAdd={async (data) => {
"use server"
return await addCategoryAction(user.id, data as Prisma.CategoryCreateInput)
}}
onEdit={async (code, data) => {
"use server"
return await editCategoryAction(user.id, code, data as Prisma.CategoryUpdateInput)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { addCurrencyAction, deleteCurrencyAction, editCurrencyAction } from "@/app/(app)/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrentUser } from "@/lib/auth"
import { getCurrencies } from "@/models/currencies"
export default async function CurrenciesSettingsPage() {
const user = await getCurrentUser()
const currencies = await getCurrencies(user.id)
const currenciesWithActions = currencies.map((currency) => ({
...currency,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-2">Currencies</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
Custom currencies would not be automatically converted but you still can have them.
</p>
<CrudTable
items={currenciesWithActions}
columns={[
{ key: "code", label: "Code", editable: true },
{ key: "name", label: "Name", editable: true },
]}
onDelete={async (code) => {
"use server"
return await deleteCurrencyAction(user.id, code)
}}
onAdd={async (data) => {
"use server"
return await addCurrencyAction(user.id, data as { code: string; name: string })
}}
onEdit={async (code, data) => {
"use server"
return await editCurrencyAction(user.id, code, data as { name: string })
}}
/>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/(app)/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrentUser } from "@/lib/auth"
import { getFields } from "@/models/fields"
import { Prisma } from "@prisma/client"
export default async function FieldsSettingsPage() {
const user = await getCurrentUser()
const fields = await getFields(user.id)
const fieldsWithActions = fields.map((field) => ({
...field,
isEditable: true,
isDeletable: field.isExtra,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-2">Custom Fields</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
You can add new fields to your transactions. Standard fields can't be removed but you can tweak their prompts or
hide them. If you don't want a field to be analyzed by AI but filled in by hand, leave the "LLM prompt" empty.
</p>
<CrudTable
items={fieldsWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{
key: "type",
label: "Type",
type: "select",
options: ["string", "number", "boolean"],
defaultValue: "string",
editable: true,
},
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{
key: "isVisibleInList",
label: "Show in transactions table",
type: "checkbox",
defaultValue: false,
editable: true,
},
{
key: "isVisibleInAnalysis",
label: "Show in analysis form",
type: "checkbox",
defaultValue: false,
editable: true,
},
]}
onDelete={async (code) => {
"use server"
return await deleteFieldAction(user.id, code)
}}
onAdd={async (data) => {
"use server"
return await addFieldAction(user.id, data as Prisma.FieldCreateInput)
}}
onEdit={async (code, data) => {
"use server"
return await editFieldAction(user.id, code, data as Prisma.FieldUpdateInput)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { SideNav } from "@/components/settings/side-nav"
import { Separator } from "@/components/ui/separator"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Settings",
description: "Customize your settings here",
}
const settingsCategories = [
{
title: "General",
href: "/settings",
},
{
title: "My Profile",
href: "/settings/profile",
},
{
title: "LLM settings",
href: "/settings/llm",
},
{
title: "Fields",
href: "/settings/fields",
},
{
title: "Categories",
href: "/settings/categories",
},
{
title: "Projects",
href: "/settings/projects",
},
{
title: "Currencies",
href: "/settings/currencies",
},
{
title: "Backups",
href: "/settings/backups",
},
]
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="space-y-6 p-10 pb-16">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">Customize your settings here</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SideNav items={settingsCategories} />
</aside>
<div className="flex w-full">{children}</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,18 @@
import LLMSettingsForm from "@/components/settings/llm-settings-form"
import { getCurrentUser } from "@/lib/auth"
import { getFields } from "@/models/fields"
import { getSettings } from "@/models/settings"
export default async function LlmSettingsPage() {
const user = await getCurrentUser()
const settings = await getSettings(user.id)
const fields = await getFields(user.id)
return (
<>
<div className="w-full max-w-2xl">
<LLMSettingsForm settings={settings} fields={fields} />
</div>
</>
)
}

View File

@@ -0,0 +1,10 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex flex-col gap-4 w-full">
<Skeleton className="h-10 w-56" />
<Skeleton className="w-full h-[350px]" />
</div>
)
}

View File

@@ -0,0 +1,20 @@
import GlobalSettingsForm from "@/components/settings/global-settings-form"
import { getCurrentUser } from "@/lib/auth"
import { getCategories } from "@/models/categories"
import { getCurrencies } from "@/models/currencies"
import { getSettings } from "@/models/settings"
export default async function SettingsPage() {
const user = await getCurrentUser()
const settings = await getSettings(user.id)
const currencies = await getCurrencies(user.id)
const categories = await getCategories(user.id)
return (
<>
<div className="w-full max-w-2xl">
<GlobalSettingsForm settings={settings} currencies={currencies} categories={categories} />
</div>
</>
)
}

View File

@@ -0,0 +1,14 @@
import ProfileSettingsForm from "@/components/settings/profile-settings-form copy"
import { getCurrentUser } from "@/lib/auth"
export default async function ProfileSettingsPage() {
const user = await getCurrentUser()
return (
<>
<div className="w-full max-w-2xl">
<ProfileSettingsForm user={user} />
</div>
</>
)
}

View File

@@ -0,0 +1,46 @@
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/(app)/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrentUser } from "@/lib/auth"
import { randomHexColor } from "@/lib/utils"
import { getProjects } from "@/models/projects"
import { Prisma } from "@prisma/client"
export default async function ProjectsSettingsPage() {
const user = await getCurrentUser()
const projects = await getProjects(user.id)
const projectsWithActions = projects.map((project) => ({
...project,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-2">Projects</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
Use projects to differentiate between the type of activities you do For example: Freelancing, YouTube channel,
Blogging. Projects are just a convenient way to separate statistics.
</p>
<CrudTable
items={projectsWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{ key: "color", label: "Color", defaultValue: randomHexColor(), editable: true },
]}
onDelete={async (code) => {
"use server"
return await deleteProjectAction(user.id, code)
}}
onAdd={async (data) => {
"use server"
return await addProjectAction(user.id, data as Prisma.ProjectCreateInput)
}}
onEdit={async (code, data) => {
"use server"
return await editProjectAction(user.id, code, data as Prisma.ProjectUpdateInput)
}}
/>
</div>
)
}