better mobile layout

This commit is contained in:
songtianlun 2025-08-03 09:52:59 +08:00
parent 8b5d5ce5eb
commit f0d9797cac
4 changed files with 129 additions and 91 deletions

View File

@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createServerSupabaseClient } from '@/lib/supabase-server'
import { addSystemGiftCredit } from '@/lib/credits'
// POST /api/users/sync - 同步Supabase用户到Prisma数据库
export async function POST(_request: NextRequest) {
export async function POST() {
try {
const supabase = await createServerSupabaseClient()
const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser()
@ -68,7 +68,7 @@ export async function POST(_request: NextRequest) {
}
// GET /api/users/sync - 获取当前用户信息
export async function GET(_request: NextRequest) {
export async function GET() {
try {
const supabase = await createServerSupabaseClient()
const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser()

View File

@ -121,29 +121,31 @@ export default function NewPromptPage() {
<div className="border-b">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="flex items-center space-x-2"
className="flex items-center space-x-2 self-start"
>
<ArrowLeft className="h-4 w-4" />
<span>{t('backToList')}</span>
<span className="hidden sm:inline">{t('backToList')}</span>
<span className="sm:hidden">Back</span>
</Button>
<div>
<h1 className="text-2xl font-bold text-foreground">{t('newPrompt')}</h1>
<h1 className="text-xl sm:text-2xl font-bold text-foreground">{t('newPrompt')}</h1>
<p className="text-sm text-muted-foreground">Create a new AI prompt</p>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2 order-first sm:order-last">
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
disabled={isSaving}
className="flex-1 sm:flex-none h-10"
>
{tCommon('cancel')}
</Button>
@ -152,7 +154,7 @@ export default function NewPromptPage() {
size="sm"
onClick={handleSave}
disabled={isSaving || !promptName.trim()}
className="flex items-center space-x-2"
className="flex items-center space-x-2 flex-1 sm:flex-none h-10"
>
{isSaving ? (
<LoadingSpinner size="sm" className="mr-2" />
@ -166,11 +168,11 @@ export default function NewPromptPage() {
</div>
</div>
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="space-y-6">
<div className="max-w-4xl mx-auto px-4 py-6 sm:py-8">
<div className="space-y-4 sm:space-y-6">
{/* Basic Information */}
<div className="bg-card rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">Basic Information</h2>
<div className="bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-4">Basic Information</h2>
<div className="space-y-4">
<div>
@ -199,25 +201,27 @@ export default function NewPromptPage() {
</div>
{/* Tags */}
<div className="bg-card rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">{t('tags')}</h2>
<div className="bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-4">{t('tags')}</h2>
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('addTag')}
className="flex-1"
className="flex-1 h-10"
/>
<Button
type="button"
onClick={handleAddTag}
disabled={!newTag.trim() || tags.includes(newTag.trim())}
className="h-10 sm:w-auto w-full"
>
<Tag className="h-4 w-4 mr-2" />
{t('addTag')}
<span className="hidden sm:inline">{t('addTag')}</span>
<span className="sm:hidden">Add</span>
</Button>
</div>
@ -245,7 +249,7 @@ export default function NewPromptPage() {
{availableTags.length > 0 && (
<div className="mt-4">
<p className="text-sm text-muted-foreground mb-2">Quick add from existing tags:</p>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{availableTags
.filter(tag => !tags.includes(tag))
.slice(0, 8)
@ -266,8 +270,8 @@ export default function NewPromptPage() {
</div>
{/* Prompt Content */}
<div className="bg-card rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">{t('promptContent')}</h2>
<div className="bg-card rounded-lg border border-border p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-semibold text-foreground mb-4">{t('promptContent')}</h2>
<div>
<Label htmlFor="promptContent">Content</Label>
@ -276,7 +280,7 @@ export default function NewPromptPage() {
value={promptContent}
onChange={(e) => setPromptContent(e.target.value)}
placeholder="Enter your prompt here..."
className="mt-1 min-h-[300px] font-mono text-sm"
className="mt-1 min-h-[200px] sm:min-h-[300px] font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-2">
Write your prompt instructions here. You can use variables like {'{variable}'} for dynamic content.

View File

@ -292,21 +292,22 @@ export default function StudioPage() {
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-foreground">{t('title')}</h1>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-4">
<div className="flex-1">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">{t('title')}</h1>
<p className="text-muted-foreground mt-1">{t('myPrompts')}</p>
</div>
<div className="flex items-center space-x-3">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
<Button
variant="outline"
onClick={() => setIsBulkAddModalOpen(true)}
className="flex items-center space-x-2"
className="flex items-center justify-center space-x-2 h-10"
>
<FolderPlus className="h-4 w-4" />
<span>Bulk Add</span>
<span className="hidden sm:inline">Bulk Add</span>
<span className="sm:hidden">Bulk</span>
</Button>
<Button onClick={handleCreatePrompt} className="flex items-center space-x-2">
<Button onClick={handleCreatePrompt} className="flex items-center justify-center space-x-2 h-10">
<Plus className="h-4 w-4" />
<span>{t('createPrompt')}</span>
</Button>
@ -314,29 +315,27 @@ export default function StudioPage() {
</div>
{/* Search and Filters */}
<div className="bg-muted/30 rounded-xl p-4 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
<div className="bg-muted/30 rounded-xl p-3 sm:p-4 mb-6">
<div className="space-y-3">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('searchPrompts')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 bg-background border-border/50 focus:border-primary transition-colors"
/>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('searchPrompts')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 bg-background border-border/50 focus:border-primary transition-colors h-10"
/>
</div>
{/* Filters Row */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex flex-wrap gap-2">
{/* Tag Filter */}
<div className="relative">
<div className="relative flex-1 min-w-[120px]">
<select
value={selectedTag}
onChange={(e) => setSelectedTag(e.target.value)}
className="appearance-none bg-background border border-border/50 rounded-lg px-3 py-2 pr-8 min-w-[130px] focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors text-sm"
className="appearance-none bg-background border border-border/50 rounded-lg px-3 py-2 pr-8 w-full focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors text-sm h-10"
>
<option value="">{t('allTags')}</option>
{allTags.map(tag => (
@ -347,7 +346,7 @@ export default function StudioPage() {
</div>
{/* Sort */}
<div className="relative">
<div className="relative flex-1 min-w-[140px]">
<select
value={`${sortField}-${sortOrder}`}
onChange={(e) => {
@ -355,27 +354,25 @@ export default function StudioPage() {
setSortField(field as SortField)
setSortOrder(order as SortOrder)
}}
className="appearance-none bg-background border border-border/50 rounded-lg px-3 py-2 pr-8 min-w-[150px] focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors text-sm"
className="appearance-none bg-background border border-border/50 rounded-lg px-3 py-2 pr-8 w-full focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors text-sm h-10"
>
<option value="updatedAt-desc">{t('sortByUpdated')} ({t('descending')})</option>
<option value="updatedAt-asc">{t('sortByUpdated')} ({t('ascending')})</option>
<option value="createdAt-desc">{t('sortByDate')} ({t('descending')})</option>
<option value="createdAt-asc">{t('sortByDate')} ({t('ascending')})</option>
<option value="name-asc">{t('sortByName')} ({t('ascending')})</option>
<option value="name-desc">{t('sortByName')} ({t('descending')})</option>
<option value="lastUsed-desc">{t('lastUsed')} ({t('descending')})</option>
<option value="lastUsed-asc">{t('lastUsed')} ({t('ascending')})</option>
<option value="updatedAt-desc">Latest</option>
<option value="updatedAt-asc">Oldest</option>
<option value="name-asc">A-Z</option>
<option value="name-desc">Z-A</option>
<option value="createdAt-desc">Created Latest</option>
<option value="createdAt-asc">Created Oldest</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
</div>
{/* View Mode Toggle */}
<div className="flex items-center bg-background border border-border/50 rounded-lg overflow-hidden">
<div className="flex items-center bg-background border border-border/50 rounded-lg overflow-hidden h-10">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="rounded-none border-r border-border/20 h-9"
className="rounded-none border-r border-border/20 h-full px-3"
title="Grid View"
>
<Grid className="h-4 w-4" />
@ -384,7 +381,7 @@ export default function StudioPage() {
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="rounded-none h-9"
className="rounded-none h-full px-3"
title="List View"
>
<List className="h-4 w-4" />
@ -394,11 +391,11 @@ export default function StudioPage() {
</div>
{/* Quick Stats */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border/30">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mt-3 pt-3 border-t border-border/30">
<div className="text-sm text-muted-foreground">
{prompts.length > 0 ? (
<span>
Showing <span className="font-medium text-foreground">{prompts.length}</span> of <span className="font-medium text-foreground">{pagination.total}</span> prompts
<span className="hidden sm:inline">Showing </span><span className="font-medium text-foreground">{prompts.length}</span> of <span className="font-medium text-foreground">{pagination.total}</span> prompts
</span>
) : (
<span>No prompts found</span>
@ -409,7 +406,7 @@ export default function StudioPage() {
variant="ghost"
size="sm"
onClick={() => setSearchQuery('')}
className="text-xs text-muted-foreground hover:text-foreground"
className="text-xs text-muted-foreground hover:text-foreground self-start sm:self-auto"
>
Clear search
</Button>
@ -441,7 +438,7 @@ export default function StudioPage() {
<>
{/* Prompts Grid/List */}
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 lg:gap-6 mb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4 lg:gap-6 mb-8">
{currentPrompts.map((prompt) => (
<div
key={prompt.id}
@ -449,14 +446,14 @@ export default function StudioPage() {
onClick={() => handleShowDetails(prompt)}
>
{/* Card Header */}
<div className="p-4 pb-3 flex-1">
<div className="p-3 sm:p-4 pb-3 flex-1">
<div className="mb-3">
<h3 className="font-semibold text-foreground text-sm leading-tight mb-2 group-hover:text-primary transition-colors">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight mb-2 group-hover:text-primary transition-colors">
<span className="line-clamp-2 break-words" title={prompt.name}>
{prompt.name}
</span>
</h3>
<div className="h-10 overflow-hidden">
<div className="h-8 sm:h-10 overflow-hidden">
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{prompt.description || 'No description available'}
</p>
@ -490,7 +487,7 @@ export default function StudioPage() {
</div>
{/* Card Footer */}
<div className="px-4 pb-4">
<div className="px-3 sm:px-4 pb-3 sm:pb-4">
{/* Metadata */}
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3 pt-2 border-t border-border/50">
<div className="flex items-center space-x-1">
@ -524,7 +521,7 @@ export default function StudioPage() {
</div>
{/* Action Buttons */}
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1 sm:space-x-2">
<Button
variant="outline"
size="sm"
@ -532,10 +529,11 @@ export default function StudioPage() {
e.stopPropagation()
handleDebugPrompt(prompt.id)
}}
className="flex-1 h-8 text-xs font-medium hover:bg-primary hover:text-primary-foreground transition-colors"
className="flex-1 h-7 sm:h-8 text-xs font-medium hover:bg-primary hover:text-primary-foreground transition-colors"
>
<Play className="h-3 w-3 mr-1" />
Open Studio
<span className="hidden sm:inline">Open Studio</span>
<span className="sm:hidden">Open</span>
</Button>
<Button
variant="ghost"
@ -544,7 +542,7 @@ export default function StudioPage() {
e.stopPropagation()
handleQuickEdit(prompt)
}}
className="h-8 w-8 p-0 hover:bg-muted transition-colors"
className="h-7 sm:h-8 w-7 sm:w-8 p-0 hover:bg-muted transition-colors"
title="Quick Edit"
>
<Edit className="h-3 w-3" />
@ -697,22 +695,26 @@ export default function StudioPage() {
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{t('page')} {pagination.page} {t('of')} {pagination.totalPages} {t('total')} {pagination.total}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
<div className="text-sm text-muted-foreground text-center sm:text-left">
Page {pagination.page} of {pagination.totalPages}
<span className="hidden sm:inline"> Total {pagination.total}</span>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center justify-center space-x-1 sm:space-x-2">
<Button
variant="outline"
size="sm"
disabled={pagination.page === 1}
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
className="h-8 px-2 sm:px-3"
>
Previous
<span className="hidden sm:inline">Previous</span>
<span className="sm:hidden">Prev</span>
</Button>
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
{/* Show fewer page numbers on mobile */}
{Array.from({ length: Math.min(3, pagination.totalPages) }, (_, i) => {
const page = i + 1
return (
<Button
@ -720,6 +722,7 @@ export default function StudioPage() {
variant={pagination.page === page ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentPage(page)}
className="h-8 w-8 p-0"
>
{page}
</Button>
@ -731,8 +734,10 @@ export default function StudioPage() {
size="sm"
disabled={pagination.page === pagination.totalPages}
onClick={() => setCurrentPage(prev => Math.min(pagination.totalPages, prev + 1))}
className="h-8 px-2 sm:px-3"
>
Next
<span className="hidden sm:inline">Next</span>
<span className="sm:hidden">Next</span>
</Button>
</div>
</div>

View File

@ -2,30 +2,51 @@
import { createClient } from '@/lib/supabase'
import { User } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const supabase = createClient()
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const lastSyncTimeRef = useRef<number>(0)
const lastUserIdRef = useRef<string | null>(null)
useEffect(() => {
// 同步用户到Prisma数据库
const syncUser = async () => {
try {
await fetch('/api/users/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Failed to sync user:', error)
// 同步用户到Prisma数据库 - 带防抖和缓存
const syncUser = async (userId: string) => {
const now = Date.now()
const timeSinceLastSync = now - lastSyncTimeRef.current
// 如果是同一个用户且距离上次同步不到30秒则跳过
if (lastUserIdRef.current === userId && timeSinceLastSync < 30000) {
return
}
// 清除之前的定时器
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current)
}
// 防抖处理延迟500ms执行避免频繁调用
syncTimeoutRef.current = setTimeout(async () => {
try {
await fetch('/api/users/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
lastSyncTimeRef.current = Date.now()
lastUserIdRef.current = userId
} catch (error) {
console.error('Failed to sync user:', error)
}
}, 500)
}
const getUser = async () => {
const { data: { user: userData } } = await supabase.auth.getUser()
if (userData) {
await syncUser()
await syncUser(userData.id)
}
setUser(userData)
setLoading(false)
@ -35,14 +56,22 @@ export function useAuth() {
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
const userData = session?.user ?? null
if (userData && (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED')) {
await syncUser()
// 只在首次登录时同步不在token刷新时同步
if (userData && event === 'SIGNED_IN') {
await syncUser(userData.id)
}
setUser(userData)
setLoading(false)
})
return () => subscription.unsubscribe()
return () => {
subscription.unsubscribe()
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current)
}
}
}, [supabase.auth])
const signOut = async () => {