fix prompt edit

This commit is contained in:
songtianlun 2025-07-30 14:46:42 +08:00
parent 2b2e311c58
commit 2707a510b5
12 changed files with 909 additions and 768 deletions

1072
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,10 @@
"name": "prmbr",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "npm run db:generate && next dev",
"build": "npm run db:generate && next build",

View File

@ -14,10 +14,9 @@ datasource db {
}
model User {
id String @id @default(cuid())
id String @id // 使用Supabase用户ID不再自动生成
email String @unique
username String @unique
password String
username String? @unique // 允许为空,因为有些用户可能没有设置用户名
avatar String?
bio String?
language String @default("en")

View File

@ -131,7 +131,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
})
}
return NextResponse.json(updatedPrompt)
// 处理返回数据格式确保tags是字符串数组
const promptWithMetadata = {
...updatedPrompt,
tags: updatedPrompt.tags.map(tag => tag.name)
}
return NextResponse.json(promptWithMetadata)
} catch (error) {
console.error('Error updating prompt:', error)

View File

@ -162,7 +162,13 @@ export async function POST(request: NextRequest) {
}
})
return NextResponse.json(prompt, { status: 201 })
// 处理返回数据格式确保tags是字符串数组
const promptWithMetadata = {
...prompt,
tags: prompt.tags.map(tag => tag.name)
}
return NextResponse.json(promptWithMetadata, { status: 201 })
} catch (error) {
console.error('Error creating prompt:', error)

View File

@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createServerSupabaseClient } from '@/lib/supabase-server'
// POST /api/users/sync - 同步Supabase用户到Prisma数据库
export async function POST(request: NextRequest) {
try {
const supabase = await createServerSupabaseClient()
const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser()
if (authError || !supabaseUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 检查用户是否已存在于Prisma数据库中
const existingUser = await prisma.user.findUnique({
where: { id: supabaseUser.id }
})
if (existingUser) {
// 用户已存在,更新信息
const updatedUser = await prisma.user.update({
where: { id: supabaseUser.id },
data: {
email: supabaseUser.email!,
username: supabaseUser.user_metadata?.username || supabaseUser.user_metadata?.full_name || null,
avatar: supabaseUser.user_metadata?.avatar_url || null,
bio: supabaseUser.user_metadata?.bio || null,
language: supabaseUser.user_metadata?.language || 'en',
updatedAt: new Date()
}
})
return NextResponse.json({
message: 'User updated successfully',
user: updatedUser
})
} else {
// 用户不存在,创建新用户
const newUser = await prisma.user.create({
data: {
id: supabaseUser.id,
email: supabaseUser.email!,
username: supabaseUser.user_metadata?.username || supabaseUser.user_metadata?.full_name || null,
avatar: supabaseUser.user_metadata?.avatar_url || null,
bio: supabaseUser.user_metadata?.bio || null,
language: supabaseUser.user_metadata?.language || 'en'
}
})
return NextResponse.json({
message: 'User created successfully',
user: newUser
}, { status: 201 })
}
} catch (error) {
console.error('Error syncing user:', error)
return NextResponse.json(
{ error: 'Failed to sync user' },
{ status: 500 }
)
}
}
// GET /api/users/sync - 获取当前用户信息
export async function GET(request: NextRequest) {
try {
const supabase = await createServerSupabaseClient()
const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser()
if (authError || !supabaseUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 从Prisma数据库获取用户信息
const user = await prisma.user.findUnique({
where: { id: supabaseUser.id }
})
if (!user) {
return NextResponse.json({ error: 'User not found in database' }, { status: 404 })
}
return NextResponse.json({ user })
} catch (error) {
console.error('Error fetching user:', error)
return NextResponse.json(
{ error: 'Failed to fetch user' },
{ status: 500 }
)
}
}

View File

@ -49,23 +49,18 @@ export default function ProfilePage() {
new: false,
confirm: false
})
const [saveStatus, setSaveStatus] = useState<{type: 'success' | 'error' | null, message: string}>({type: null, message: ''})
const [saveStatus, setSaveStatus] = useState<{ type: 'success' | 'error' | null, message: string }>({ type: null, message: '' })
const [isLoading, setIsLoading] = useState(false)
const [profileLoading, setProfileLoading] = useState(true)
const [fieldLoading, setFieldLoading] = useState<{[key: string]: boolean}>({})
const [fieldLoading, setFieldLoading] = useState<{ [key: string]: boolean }>({})
const [avatarUploading, setAvatarUploading] = useState(false)
const supabase = createClient()
useEffect(() => {
if (user) {
loadProfile()
}
}, [user]) // eslint-disable-line react-hooks/exhaustive-deps
const loadProfile = async () => {
if (!user) return
const loadProfile = async () => {
setProfileLoading(true)
try {
// Get user metadata and profile data
@ -94,18 +89,21 @@ export default function ProfilePage() {
})
} catch (error) {
console.error('Error loading profile:', error)
setSaveStatus({type: 'error', message: t('failedToLoadProfile')})
setSaveStatus({ type: 'error', message: t('failedToLoadProfile') })
} finally {
setProfileLoading(false)
}
}
loadProfile()
}, [user, supabase, t])
const updateProfile = async (field: string, value: string) => {
if (!user) return
setFieldLoading(prev => ({ ...prev, [field]: true }))
setIsLoading(true)
setSaveStatus({type: null, message: ''})
setSaveStatus({ type: null, message: '' })
try {
const updates: Record<string, string> = {}
@ -113,14 +111,14 @@ export default function ProfilePage() {
if (field === 'email') {
const { error } = await supabase.auth.updateUser({ email: value })
if (error) throw error
setSaveStatus({type: 'success', message: t('checkEmailToConfirm')})
setSaveStatus({ type: 'success', message: t('checkEmailToConfirm') })
} else {
updates[field] = value
const { error } = await supabase.auth.updateUser({
data: updates
})
if (error) throw error
setSaveStatus({type: 'success', message: `${field} ${t('updatedSuccessfully')}`})
setSaveStatus({ type: 'success', message: `${field} ${t('updatedSuccessfully')}` })
}
// Reload profile
@ -130,7 +128,7 @@ export default function ProfilePage() {
setIsEditing(prev => ({ ...prev, [field]: false }))
} catch (error: unknown) {
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || `${t('failedToUpdate')} ${field}`})
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || `${t('failedToUpdate')} ${field}` })
} finally {
setIsLoading(false)
setFieldLoading(prev => ({ ...prev, [field]: false }))
@ -139,18 +137,18 @@ export default function ProfilePage() {
const updatePassword = async () => {
if (!formData.newPassword || formData.newPassword !== formData.confirmPassword) {
setSaveStatus({type: 'error', message: t('passwordsNotMatch')})
setSaveStatus({ type: 'error', message: t('passwordsNotMatch') })
return
}
if (formData.newPassword.length < 6) {
setSaveStatus({type: 'error', message: t('passwordMinLength')})
setSaveStatus({ type: 'error', message: t('passwordMinLength') })
return
}
setFieldLoading(prev => ({ ...prev, password: true }))
setIsLoading(true)
setSaveStatus({type: null, message: ''})
setSaveStatus({ type: null, message: '' })
try {
const { error } = await supabase.auth.updateUser({
@ -159,7 +157,7 @@ export default function ProfilePage() {
if (error) throw error
setSaveStatus({type: 'success', message: t('passwordUpdatedSuccessfully')})
setSaveStatus({ type: 'success', message: t('passwordUpdatedSuccessfully') })
setFormData(prev => ({
...prev,
currentPassword: '',
@ -169,7 +167,7 @@ export default function ProfilePage() {
setIsEditing(prev => ({ ...prev, password: false }))
} catch (error: unknown) {
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUpdatePassword')})
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUpdatePassword') })
} finally {
setIsLoading(false)
setFieldLoading(prev => ({ ...prev, password: false }))
@ -182,7 +180,7 @@ export default function ProfilePage() {
setAvatarUploading(true)
setIsLoading(true)
setSaveStatus({type: null, message: ''})
setSaveStatus({ type: null, message: '' })
try {
// Create a unique filename for future use with actual file storage
@ -202,14 +200,14 @@ export default function ProfilePage() {
if (error) throw error
await loadProfile()
setSaveStatus({type: 'success', message: t('avatarUpdatedSuccessfully')})
setSaveStatus({ type: 'success', message: t('avatarUpdatedSuccessfully') })
setIsLoading(false)
setAvatarUploading(false)
}
reader.readAsDataURL(file)
} catch (error: unknown) {
setSaveStatus({type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUploadAvatar')})
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUploadAvatar') })
setIsLoading(false)
setAvatarUploading(false)
}
@ -248,8 +246,7 @@ export default function ProfilePage() {
</div>
{saveStatus.type && (
<div className={`mb-6 p-4 rounded-lg border ${
saveStatus.type === 'success'
<div className={`mb-6 p-4 rounded-lg border ${saveStatus.type === 'success'
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-300'
: 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>

View File

@ -63,15 +63,28 @@ export default function PromptPage({ params }: PromptPageProps) {
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const fetchPrompt = async () => {
useEffect(() => {
if (!loading && !user) {
router.push('/signin')
return
}
if (!user || !promptId) return
const fetchPrompt = async () => {
try {
setIsLoading(true)
const response = await fetch(`/api/prompts/${promptId}?userId=${user.id}`)
if (response.ok) {
const data = await response.json()
setPrompt(data)
// 确保tags是字符串数组
const processedData = {
...data,
tags: Array.isArray(data.tags)
? data.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.name || '')
: []
}
setPrompt(processedData)
setPromptContent(data.content)
setPromptTitle(data.name)
} else {
@ -85,13 +98,8 @@ export default function PromptPage({ params }: PromptPageProps) {
}
}
useEffect(() => {
if (!loading && !user) {
router.push('/signin')
} else if (user && promptId) {
fetchPrompt()
}
}, [user, loading, router, promptId, fetchPrompt])
}, [user, loading, router, promptId])
@ -195,8 +203,8 @@ export default function PromptPage({ params }: PromptPageProps) {
<div className="flex items-center space-x-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<h1 className="font-semibold text-foreground">{prompt.name}</h1>
<p className="text-xs text-muted-foreground">Version {prompt.currentVersion || 1} {prompt.usage || 0} uses</p>
<h1 className="font-semibold text-foreground">{prompt?.name || 'Loading...'}</h1>
<p className="text-xs text-muted-foreground">Version {prompt?.currentVersion || 1} {prompt?.usage || 0} uses</p>
</div>
</div>
</div>
@ -261,15 +269,15 @@ export default function PromptPage({ params }: PromptPageProps) {
<div className="flex items-start space-x-3">
<div className="w-2 h-2 rounded-full bg-primary mt-2 flex-shrink-0"></div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm truncate">{prompt.name}</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{prompt.description}</p>
<h3 className="font-medium text-foreground text-sm truncate">{prompt?.name || 'Loading...'}</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{prompt?.description || ''}</p>
<div className="flex items-center mt-2 space-x-2">
{prompt.tags.slice(0, 1).map((tag) => (
<span key={tag} className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-primary/10 text-primary rounded">
{tag}
{prompt?.tags?.slice(0, 1).map((tag) => (
<span key={typeof tag === 'string' ? tag : (tag as any)?.name || ''} className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-primary/10 text-primary rounded">
{typeof tag === 'string' ? tag : (tag as any)?.name || ''}
</span>
))}
{prompt.tags.length > 1 && (
{prompt?.tags && prompt.tags.length > 1 && (
<span className="text-xs text-muted-foreground">+{prompt.tags.length - 1}</span>
)}
</div>
@ -325,30 +333,33 @@ export default function PromptPage({ params }: PromptPageProps) {
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Created</span>
<span className="text-foreground">{new Date(prompt.createdAt).toLocaleDateString()}</span>
<span className="text-foreground">{prompt?.createdAt ? new Date(prompt.createdAt).toLocaleDateString() : '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Updated</span>
<span className="text-foreground">{new Date(prompt.updatedAt).toLocaleDateString()}</span>
<span className="text-foreground">{prompt?.updatedAt ? new Date(prompt.updatedAt).toLocaleDateString() : '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Last used</span>
<span className="text-foreground">{prompt.lastUsed ? new Date(prompt.lastUsed).toLocaleDateString() : 'Never'}</span>
<span className="text-foreground">{prompt?.lastUsed ? new Date(prompt.lastUsed).toLocaleDateString() : 'Never'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Usage count</span>
<span className="text-foreground">{prompt.usage || 0}</span>
<span className="text-foreground">{prompt?.usage || 0}</span>
</div>
<div className="pt-2 border-t border-border">
<div className="flex flex-wrap gap-1">
{prompt.tags.map((tag: string) => (
{prompt?.tags?.map((tag: any) => {
const tagName = typeof tag === 'string' ? tag : tag?.name || '';
return (
<span
key={tag}
key={tagName}
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full"
>
{tag}
{tagName}
</span>
))}
);
})}
</div>
</div>
</div>

View File

@ -31,9 +31,15 @@ export default function NewPromptPage() {
const [isSaving, setIsSaving] = useState(false)
const [availableTags, setAvailableTags] = useState<string[]>([])
const fetchAvailableTags = async () => {
useEffect(() => {
if (!loading && !user) {
router.push('/signin')
return
}
if (!user) return
const fetchAvailableTags = async () => {
try {
const response = await fetch(`/api/tags?userId=${user.id}`)
if (response.ok) {
@ -45,13 +51,8 @@ export default function NewPromptPage() {
}
}
useEffect(() => {
if (!loading && !user) {
router.push('/signin')
} else if (user) {
fetchAvailableTags()
}
}, [user, loading, router, fetchAvailableTags])
}, [user, loading, router])

View File

@ -78,16 +78,6 @@ export default function StudioPage() {
totalPages: 0
})
useEffect(() => {
if (!loading && !user) {
router.push('/signin')
} else if (user) {
fetchPrompts()
fetchTags()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading, router])
// Fetch prompts from API
const fetchPrompts = async () => {
if (!user) return
@ -117,10 +107,16 @@ export default function StudioPage() {
}
}
// Fetch tags from API
const fetchTags = async () => {
// Initial load effect
useEffect(() => {
if (!loading && !user) {
router.push('/signin')
return
}
if (!user) return
const fetchTags = async () => {
try {
const response = await fetch(`/api/tags?userId=${user.id}`)
if (response.ok) {
@ -132,12 +128,41 @@ export default function StudioPage() {
}
}
fetchPrompts()
fetchTags()
}, [user, loading, router])
// Refetch when filters change
useEffect(() => {
if (user) {
fetchPrompts()
if (!user) return
const fetchPromptsWithFilters = async () => {
try {
setIsLoading(true)
const params = new URLSearchParams({
userId: user.id,
page: currentPage.toString(),
limit: itemsPerPage.toString(),
search: searchQuery,
tag: selectedTag,
sortBy: sortField,
sortOrder: sortOrder
})
const response = await fetch(`/api/prompts?${params}`)
if (response.ok) {
const data = await response.json()
setPrompts(data.prompts)
setPagination(data.pagination)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} catch (error) {
console.error('Error fetching prompts:', error)
} finally {
setIsLoading(false)
}
}
fetchPromptsWithFilters()
}, [currentPage, itemsPerPage, searchQuery, selectedTag, sortField, sortOrder, user])
// Since filtering and sorting is now done on the server,
@ -411,8 +436,7 @@ export default function StudioPage() {
{currentPrompts.map((prompt) => (
<div
key={prompt.id}
className={`bg-card rounded-lg border border-border p-4 hover:shadow-md transition-shadow cursor-pointer ${
selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
className={`bg-card rounded-lg border border-border p-4 hover:shadow-md transition-shadow cursor-pointer ${selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
}`}
onClick={() => handleSelectPrompt(prompt.id)}
>
@ -442,15 +466,18 @@ export default function StudioPage() {
{/* Tags */}
<div className="flex flex-wrap gap-1 mb-3">
{prompt.tags.slice(0, 3).map((tag) => (
{prompt.tags?.slice(0, 3).map((tag: any) => {
const tagName = typeof tag === 'string' ? tag : tag?.name || '';
return (
<span
key={tag}
key={tagName}
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-muted text-muted-foreground rounded-full"
>
{tag}
{tagName}
</span>
))}
{prompt.tags.length > 3 && (
);
})}
{prompt.tags && prompt.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{prompt.tags.length - 3}
</span>
@ -526,8 +553,7 @@ export default function StudioPage() {
{currentPrompts.map((prompt) => (
<div
key={prompt.id}
className={`flex items-center p-3 bg-card rounded-lg border border-border hover:shadow-sm transition-shadow ${
selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
className={`flex items-center p-3 bg-card rounded-lg border border-border hover:shadow-sm transition-shadow ${selectedPrompts.includes(prompt.id) ? 'ring-2 ring-primary' : ''
}`}
>
<div className="w-8">

View File

@ -53,7 +53,11 @@ export function EditPromptModal({
if (isOpen) {
setName(prompt.name)
setDescription(prompt.description || '')
setTags(prompt.tags)
// 确保tags是字符串数组
const processedTags = Array.isArray(prompt.tags)
? prompt.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.name || '')
: []
setTags(processedTags)
fetchAvailableTags()
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -10,16 +10,35 @@ export function useAuth() {
const supabase = createClient()
useEffect(() => {
// 同步用户到Prisma数据库
const syncUser = async (_userData: User) => {
try {
await fetch('/api/users/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Failed to sync user:', error)
}
}
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser()
setUser(user)
const { data: { user: userData } } = await supabase.auth.getUser()
if (userData) {
await syncUser(userData)
}
setUser(userData)
setLoading(false)
}
getUser()
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
setUser(session?.user ?? null)
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
const userData = session?.user ?? null
if (userData && (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED')) {
await syncUser(userData)
}
setUser(userData)
setLoading(false)
})