güncelleme
This commit is contained in:
60
components/dashboard/auto-logout-handler.tsx
Normal file
60
components/dashboard/auto-logout-handler.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { createClient } from "@/lib/supabase-browser"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const INACTIVITY_TIMEOUT = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
export function AutoLogoutHandler() {
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await supabase.auth.signOut()
|
||||
toast.info("Oturumunuz uzun süre işlem yapılmadığı için sonlandırıldı.")
|
||||
router.push("/login")
|
||||
router.refresh()
|
||||
}, [router, supabase])
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(handleLogout, INACTIVITY_TIMEOUT)
|
||||
}, [handleLogout])
|
||||
|
||||
useEffect(() => {
|
||||
// Events to listen for
|
||||
const events = [
|
||||
"mousedown",
|
||||
"mousemove",
|
||||
"keydown",
|
||||
"scroll",
|
||||
"touchstart",
|
||||
]
|
||||
|
||||
// Initial set
|
||||
resetTimer()
|
||||
|
||||
// Event listener wrapper to debounce slightly/reset
|
||||
const onUserActivity = () => {
|
||||
resetTimer()
|
||||
}
|
||||
|
||||
events.forEach((event) => {
|
||||
window.addEventListener(event, onUserActivity)
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
events.forEach((event) => {
|
||||
window.removeEventListener(event, onUserActivity)
|
||||
})
|
||||
}
|
||||
}, [resetTimer])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -23,9 +23,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Loader2, X, UploadCloud } from "lucide-react"
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { createClient } from "@/lib/supabase-browser"
|
||||
import Image from "next/image"
|
||||
|
||||
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
|
||||
|
||||
const productSchema = z.object({
|
||||
name: z.string().min(2, "Ürün adı en az 2 karakter olmalıdır"),
|
||||
@@ -33,11 +39,12 @@ const productSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"),
|
||||
image_url: z.string().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
images: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
type ProductFormValues = z.infer<typeof productSchema>
|
||||
|
||||
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
|
||||
|
||||
// Define the shape of data coming from Supabase
|
||||
interface Product {
|
||||
@@ -48,6 +55,10 @@ interface Product {
|
||||
price: number
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
is_active?: boolean
|
||||
// images? we might need to fetch them separately if they are in another table,
|
||||
// but for now let's assume update passes them if fetched, or we can handle it later.
|
||||
// Ideally the server component fetches relation.
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
@@ -57,6 +68,13 @@ interface ProductFormProps {
|
||||
export function ProductForm({ initialData }: ProductFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [previewImages, setPreviewImages] = useState<string[]>(
|
||||
initialData?.image_url ? [initialData.image_url] : []
|
||||
)
|
||||
// Note: initialData probably only has single image_url field unless we updated the fetch query.
|
||||
// For MVP phase 1, we just sync with image_url or expect 'images' prop if we extended it.
|
||||
// I will add a local state for images.
|
||||
|
||||
const form = useForm<ProductFormValues>({
|
||||
resolver: zodResolver(productSchema) as Resolver<ProductFormValues>,
|
||||
@@ -66,15 +84,93 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
||||
description: initialData.description || "",
|
||||
price: initialData.price,
|
||||
image_url: initialData.image_url || "",
|
||||
is_active: initialData.is_active ?? true,
|
||||
images: initialData.image_url ? [initialData.image_url] : []
|
||||
} : {
|
||||
name: "",
|
||||
category: "",
|
||||
description: "",
|
||||
price: 0,
|
||||
image_url: "",
|
||||
is_active: true,
|
||||
images: []
|
||||
},
|
||||
})
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
const supabase = createClient()
|
||||
const uploadedUrls: string[] = [...form.getValues("images") || []]
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
|
||||
// Compression
|
||||
const options = {
|
||||
maxSizeMB: 1, // Max 1MB
|
||||
maxWidthOrHeight: 1920,
|
||||
useWebWorker: true
|
||||
}
|
||||
|
||||
let compressedFile = file
|
||||
try {
|
||||
compressedFile = await imageCompression(file, options)
|
||||
} catch (error) {
|
||||
console.error("Compression error:", error)
|
||||
// Fallback to original
|
||||
}
|
||||
|
||||
// Upload
|
||||
const fileExt = file.name.split('.').pop()
|
||||
const fileName = `${Math.random().toString(36).substring(2)}_${Date.now()}.${fileExt}`
|
||||
const filePath = `products/${fileName}`
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('products') // Assuming 'products' bucket exists
|
||||
.upload(filePath, compressedFile)
|
||||
|
||||
if (uploadError) {
|
||||
console.error(uploadError)
|
||||
toast.error(`Resim yüklenemedi: ${file.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get URL
|
||||
const { data } = supabase.storage.from('products').getPublicUrl(filePath)
|
||||
uploadedUrls.push(data.publicUrl)
|
||||
}
|
||||
|
||||
// Update form
|
||||
form.setValue("images", uploadedUrls)
|
||||
// Set first image as main
|
||||
if (uploadedUrls.length > 0) {
|
||||
form.setValue("image_url", uploadedUrls[0])
|
||||
}
|
||||
setPreviewImages(uploadedUrls)
|
||||
|
||||
} catch {
|
||||
toast.error("Yükleme sırasında hata oluştu")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
const currentImages = [...form.getValues("images") || []]
|
||||
currentImages.splice(index, 1)
|
||||
form.setValue("images", currentImages)
|
||||
if (currentImages.length > 0) {
|
||||
form.setValue("image_url", currentImages[0])
|
||||
} else {
|
||||
form.setValue("image_url", "")
|
||||
}
|
||||
setPreviewImages(currentImages)
|
||||
}
|
||||
|
||||
async function onSubmit(data: ProductFormValues) {
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -104,21 +200,45 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-2xl">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ürün Adı</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Çelik Kasa Model X" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4 shadow-sm w-full max-w-xs">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Aktif Durum</FormLabel>
|
||||
<FormDescription>
|
||||
Ürün sitede görüntülensin mi?
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ürün Adı</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Çelik Kasa Model X" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
@@ -143,39 +263,70 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Fiyat (₺)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="0.00" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="image_url"
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Görsel URL (Opsiyonel)</FormLabel>
|
||||
<FormLabel>Fiyat (₺)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." {...field} />
|
||||
<Input type="number" placeholder="0.00" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Ürün görseli için şimdilik dış bağlantı kullanın.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormLabel>Ürün Görselleri</FormLabel>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{previewImages.map((url, i) => (
|
||||
<div key={i} className="relative aspect-square border rounded-md overflow-hidden group">
|
||||
<Image
|
||||
src={url}
|
||||
alt="Preview"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label className="flex flex-col items-center justify-center w-full aspect-square border-2 border-dashed rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
{uploading ? (
|
||||
<Loader2 className="w-8 h-8 text-gray-500 animate-spin" />
|
||||
) : (
|
||||
<UploadCloud className="w-8 h-8 text-gray-500" />
|
||||
)}
|
||||
<p className="mb-2 text-sm text-gray-500">Resim Yükle</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Birden fazla resim seçebilirsiniz. Resimler otomatik olarak sıkıştırılacaktır.
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
{/* Hidden input for main image url fallback if needed */}
|
||||
<input type="hidden" {...form.register("image_url")} />
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
@@ -190,8 +341,8 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Button type="submit" disabled={loading || uploading}>
|
||||
{(loading || uploading) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{initialData ? "Güncelle" : "Oluştur"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user