mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(feedback): add user feedback/review system with admin management and hardened image upload validation
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEvent, FormEvent, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ArrowLeft, Bug, Image as ImageIcon, Loader2, MessageSquareQuote, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
type FeedbackCategory = 'BUG' | 'FEATURE' | 'OTHER';
|
||||
type TabValue = 'feedback' | 'review';
|
||||
|
||||
interface FormStatus {
|
||||
type: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('feedback');
|
||||
const [status, setStatus] = useState<FormStatus | null>(null);
|
||||
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
|
||||
const [isSubmittingReview, setIsSubmittingReview] = useState(false);
|
||||
|
||||
const [feedbackTitle, setFeedbackTitle] = useState('');
|
||||
const [feedbackCategory, setFeedbackCategory] = useState<FeedbackCategory>('BUG');
|
||||
const [feedbackMessage, setFeedbackMessage] = useState('');
|
||||
const [feedbackScreenshotFiles, setFeedbackScreenshotFiles] = useState<File[]>([]);
|
||||
const [feedbackScreenshotPreviewUrls, setFeedbackScreenshotPreviewUrls] = useState<string[]>([]);
|
||||
|
||||
const [reviewTitle, setReviewTitle] = useState('');
|
||||
const [reviewMessage, setReviewMessage] = useState('');
|
||||
const [reviewRating, setReviewRating] = useState('5');
|
||||
const [allowShowcase, setAllowShowcase] = useState(false);
|
||||
|
||||
const uploadFeedbackScreenshot = async (file: File): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch('/api/feedback/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to upload screenshot');
|
||||
}
|
||||
|
||||
return data.data.url as string;
|
||||
};
|
||||
|
||||
const handleFeedbackScreenshotChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(event.target.files ?? []);
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const remainingSlots = 5 - feedbackScreenshotFiles.length;
|
||||
if (remainingSlots <= 0) {
|
||||
setStatus({ type: 'error', message: 'You can upload up to 5 screenshots.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedFiles = selectedFiles.slice(0, remainingSlots);
|
||||
const nextFiles: File[] = [];
|
||||
const nextPreviewUrls: string[] = [];
|
||||
|
||||
for (const file of allowedFiles) {
|
||||
const isImage = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type);
|
||||
if (!isImage) {
|
||||
setStatus({ type: 'error', message: 'Unsupported screenshot format. Use JPG, PNG, WEBP, or GIF.' });
|
||||
continue;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setStatus({ type: 'error', message: 'Each screenshot must be smaller than 10MB.' });
|
||||
continue;
|
||||
}
|
||||
nextFiles.push(file);
|
||||
nextPreviewUrls.push(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
if (nextFiles.length > 0) {
|
||||
setFeedbackScreenshotFiles((prev) => [...prev, ...nextFiles]);
|
||||
setFeedbackScreenshotPreviewUrls((prev) => [...prev, ...nextPreviewUrls]);
|
||||
}
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const removeFeedbackScreenshot = (index: number) => {
|
||||
const targetUrl = feedbackScreenshotPreviewUrls[index];
|
||||
if (targetUrl) URL.revokeObjectURL(targetUrl);
|
||||
setFeedbackScreenshotFiles((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
|
||||
setFeedbackScreenshotPreviewUrls((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
|
||||
};
|
||||
|
||||
const clearFeedbackScreenshots = () => {
|
||||
feedbackScreenshotPreviewUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
setFeedbackScreenshotFiles([]);
|
||||
setFeedbackScreenshotPreviewUrls([]);
|
||||
};
|
||||
|
||||
const handleFeedbackSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setIsSubmittingFeedback(true);
|
||||
|
||||
try {
|
||||
const screenshotUrls = await Promise.all(
|
||||
feedbackScreenshotFiles.map((file) => uploadFeedbackScreenshot(file))
|
||||
);
|
||||
|
||||
const response = await fetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'FEEDBACK',
|
||||
category: feedbackCategory,
|
||||
title: feedbackTitle,
|
||||
message: feedbackMessage,
|
||||
screenshotUrls,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setStatus({ type: 'error', message: data.error || 'Failed to submit feedback' });
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedbackTitle('');
|
||||
setFeedbackCategory('BUG');
|
||||
setFeedbackMessage('');
|
||||
clearFeedbackScreenshots();
|
||||
setStatus({ type: 'success', message: 'Feedback submitted. Thank you.' });
|
||||
} catch {
|
||||
setStatus({ type: 'error', message: 'Failed to submit feedback' });
|
||||
} finally {
|
||||
setIsSubmittingFeedback(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setIsSubmittingReview(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'REVIEW',
|
||||
title: reviewTitle,
|
||||
message: reviewMessage,
|
||||
rating: Number(reviewRating),
|
||||
allowShowcase,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
setStatus({ type: 'error', message: data.error || 'Failed to submit review' });
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewTitle('');
|
||||
setReviewMessage('');
|
||||
setReviewRating('5');
|
||||
setAllowShowcase(false);
|
||||
setStatus({ type: 'success', message: 'Review submitted. Thank you for sharing your experience.' });
|
||||
} catch {
|
||||
setStatus({ type: 'error', message: 'Failed to submit review' });
|
||||
} finally {
|
||||
setIsSubmittingReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
<Link href="/dashboard" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Feedback & Review</CardTitle>
|
||||
<CardDescription>
|
||||
Send product feedback, report bugs, or share a review we can feature on the landing page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status && (
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-2 text-sm ${
|
||||
status.type === 'success'
|
||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||||
: 'border-destructive/40 bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabValue)} className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="feedback" className="gap-1.5">
|
||||
<Bug className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="review" className="gap-1.5">
|
||||
<MessageSquareQuote className="h-3.5 w-3.5" />
|
||||
Review
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="feedback">
|
||||
<form className="space-y-4 pt-2" onSubmit={handleFeedbackSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedback-title">Title</Label>
|
||||
<Input
|
||||
id="feedback-title"
|
||||
value={feedbackTitle}
|
||||
onChange={(event) => setFeedbackTitle(event.target.value)}
|
||||
placeholder="Short summary"
|
||||
minLength={3}
|
||||
maxLength={120}
|
||||
required
|
||||
disabled={isSubmittingFeedback}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select
|
||||
value={feedbackCategory}
|
||||
onValueChange={(value: FeedbackCategory) => setFeedbackCategory(value)}
|
||||
disabled={isSubmittingFeedback}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BUG">Bug report</SelectItem>
|
||||
<SelectItem value="FEATURE">Feature request</SelectItem>
|
||||
<SelectItem value="OTHER">Other feedback</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedback-message">Details</Label>
|
||||
<Textarea
|
||||
id="feedback-message"
|
||||
value={feedbackMessage}
|
||||
onChange={(event) => setFeedbackMessage(event.target.value)}
|
||||
minLength={10}
|
||||
maxLength={3000}
|
||||
placeholder="Tell us what happened, what you expected, or what you want to see."
|
||||
required
|
||||
disabled={isSubmittingFeedback}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedback-screenshot">Screenshots (optional, up to 5)</Label>
|
||||
<Input
|
||||
id="feedback-screenshot"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
multiple
|
||||
onChange={handleFeedbackScreenshotChange}
|
||||
disabled={isSubmittingFeedback || feedbackScreenshotFiles.length >= 5}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{feedbackScreenshotFiles.length}/5 selected
|
||||
</p>
|
||||
{feedbackScreenshotPreviewUrls.length > 0 && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{feedbackScreenshotPreviewUrls.map((previewUrl, index) => (
|
||||
<div key={`${previewUrl}-${index}`} className="rounded-md border p-2">
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt={`Feedback screenshot preview ${index + 1}`}
|
||||
width={640}
|
||||
height={360}
|
||||
className="max-h-48 w-full rounded-sm object-contain"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => removeFeedbackScreenshot(index)}
|
||||
disabled={isSubmittingFeedback}
|
||||
>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSubmittingFeedback}>
|
||||
{isSubmittingFeedback ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ImageIcon className="mr-2 h-4 w-4" />}
|
||||
Submit Feedback
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="review">
|
||||
<form className="space-y-4 pt-2" onSubmit={handleReviewSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="review-title">Title</Label>
|
||||
<Input
|
||||
id="review-title"
|
||||
value={reviewTitle}
|
||||
onChange={(event) => setReviewTitle(event.target.value)}
|
||||
placeholder="Your headline"
|
||||
minLength={3}
|
||||
maxLength={120}
|
||||
required
|
||||
disabled={isSubmittingReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Rating</Label>
|
||||
<Select value={reviewRating} onValueChange={setReviewRating} disabled={isSubmittingReview}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5 - Excellent</SelectItem>
|
||||
<SelectItem value="4">4 - Very good</SelectItem>
|
||||
<SelectItem value="3">3 - Good</SelectItem>
|
||||
<SelectItem value="2">2 - Needs improvement</SelectItem>
|
||||
<SelectItem value="1">1 - Poor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="review-message">Experience</Label>
|
||||
<Textarea
|
||||
id="review-message"
|
||||
value={reviewMessage}
|
||||
onChange={(event) => setReviewMessage(event.target.value)}
|
||||
minLength={10}
|
||||
maxLength={3000}
|
||||
placeholder="What has your experience been like using OpenFrame?"
|
||||
required
|
||||
disabled={isSubmittingReview}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 rounded-md border p-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={allowShowcase}
|
||||
onChange={(event) => setAllowShowcase(event.target.checked)}
|
||||
disabled={isSubmittingReview}
|
||||
/>
|
||||
<span>I allow OpenFrame to potentially showcase this review on the landing page.</span>
|
||||
</label>
|
||||
|
||||
<Button type="submit" disabled={isSubmittingReview}>
|
||||
{isSubmittingReview ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquareQuote className="mr-2 h-4 w-4" />}
|
||||
Submit Review
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { DeleteFeedbackButton } from '@/components/admin/delete-feedback-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default async function AdminFeedbackDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ feedbackId: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const { feedbackId } = await params;
|
||||
const userFeedbackDelegate = (db as unknown as {
|
||||
userFeedback?: {
|
||||
findUnique: (args?: unknown) => Promise<{
|
||||
id: string;
|
||||
type: string;
|
||||
category: string | null;
|
||||
status: string;
|
||||
rating: number | null;
|
||||
title: string;
|
||||
message: string;
|
||||
screenshotUrl: string | null;
|
||||
createdAt: Date;
|
||||
user: { name: string | null; email: string | null };
|
||||
screenshots: Array<{ id: string; url: string }>;
|
||||
} | null>;
|
||||
};
|
||||
}).userFeedback;
|
||||
|
||||
let entry = null as Awaited<ReturnType<NonNullable<typeof userFeedbackDelegate>['findUnique']>> | null;
|
||||
if (userFeedbackDelegate) {
|
||||
try {
|
||||
entry = await userFeedbackDelegate.findUnique({
|
||||
where: { id: feedbackId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
screenshots: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Unknown field `screenshots`')) {
|
||||
entry = await userFeedbackDelegate.findUnique({
|
||||
where: { id: feedbackId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as typeof entry;
|
||||
|
||||
if (entry && !Array.isArray(entry.screenshots)) {
|
||||
entry = {
|
||||
...entry,
|
||||
screenshots: [],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const screenshotItems =
|
||||
entry.screenshots.length > 0
|
||||
? entry.screenshots
|
||||
: (entry.screenshotUrl
|
||||
? [{ id: `${entry.id}-legacy`, url: entry.screenshotUrl }]
|
||||
: []);
|
||||
const submittedAtText = format(new Date(entry.createdAt), 'MMM dd, yyyy HH:mm');
|
||||
const submitterName = entry.user.name || 'there';
|
||||
const feedbackTypeLabel = entry.type.toLowerCase();
|
||||
const quotedMessage = entry.message
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
const mailtoHref = entry.user.email
|
||||
? `mailto:${entry.user.email}?subject=${encodeURIComponent(
|
||||
`[OpenFrame ${entry.type}] Re: ${entry.title}`
|
||||
)}&body=${encodeURIComponent(
|
||||
`Hi ${submitterName},\n\nThanks for your ${feedbackTypeLabel}.\n\n` +
|
||||
`I reviewed your submission:\n` +
|
||||
`Title: ${entry.title}\n` +
|
||||
`Submitted: ${submittedAtText}\n\n` +
|
||||
`Your message:\n${quotedMessage}\n\n`
|
||||
)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Feedback Detail</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/feedback">Back to list</Link>
|
||||
</Button>
|
||||
<DeleteFeedbackButton
|
||||
feedbackId={entry.id}
|
||||
feedbackTitle={entry.title}
|
||||
redirectTo="/admin/feedback"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{entry.type}</Badge>
|
||||
{entry.category && <Badge variant="secondary">{entry.category}</Badge>}
|
||||
<Badge variant="outline">{entry.status}</Badge>
|
||||
{entry.rating && <Badge variant="outline">Rating: {entry.rating}/5</Badge>}
|
||||
</div>
|
||||
<CardTitle className="text-xl">{entry.title}</CardTitle>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Submitted by {entry.user.name || 'Anonymous'} (
|
||||
{entry.user.email && mailtoHref ? (
|
||||
<a href={mailtoHref} className="underline hover:text-foreground">
|
||||
{entry.user.email}
|
||||
</a>
|
||||
) : (
|
||||
'No email'
|
||||
)}
|
||||
) on {submittedAtText}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold uppercase text-muted-foreground">Message</h3>
|
||||
<div className="whitespace-pre-wrap rounded-md border p-4 text-sm leading-relaxed">
|
||||
{entry.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold uppercase text-muted-foreground">
|
||||
Screenshots ({screenshotItems.length})
|
||||
</h3>
|
||||
{screenshotItems.length === 0 ? (
|
||||
<div className="rounded-md border p-4 text-sm text-muted-foreground">No screenshots attached.</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{screenshotItems.map((screenshot, index) => (
|
||||
<a
|
||||
key={screenshot.id}
|
||||
href={screenshot.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-md border p-2 transition-colors hover:bg-accent/30"
|
||||
>
|
||||
<Image
|
||||
src={screenshot.url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
width={1000}
|
||||
height={600}
|
||||
className="h-52 w-full rounded-sm object-contain"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Open full size</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { FeedbackEntryType, FeedbackStatus } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { DeleteFeedbackButton } from '@/components/admin/delete-feedback-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
type SortBy = 'submittedAt' | 'type' | 'status' | 'rating' | 'user' | 'allowShowcase' | 'showOnLanding';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
type TypeFilter = 'ALL' | FeedbackEntryType;
|
||||
type StatusFilter = 'ALL' | FeedbackStatus;
|
||||
type AdminFeedbackEntry = {
|
||||
id: string;
|
||||
type: FeedbackEntryType;
|
||||
category: string | null;
|
||||
title: string;
|
||||
message: string;
|
||||
screenshotUrl: string | null;
|
||||
screenshots: Array<{ id: string; url: string }>;
|
||||
rating: number | null;
|
||||
status: FeedbackStatus;
|
||||
allowShowcase: boolean;
|
||||
showOnLanding: boolean;
|
||||
createdAt: Date;
|
||||
user: { id: string; name: string | null; email: string | null };
|
||||
};
|
||||
|
||||
function parseSortBy(value: string | undefined): SortBy {
|
||||
const accepted: SortBy[] = ['submittedAt', 'type', 'status', 'rating', 'user', 'allowShowcase', 'showOnLanding'];
|
||||
return accepted.includes(value as SortBy) ? (value as SortBy) : 'submittedAt';
|
||||
}
|
||||
|
||||
function parseSortDirection(value: string | undefined): SortDirection {
|
||||
return value === 'asc' || value === 'desc' ? value : 'desc';
|
||||
}
|
||||
|
||||
function parseTypeFilter(value: string | undefined): TypeFilter {
|
||||
if (value === FeedbackEntryType.FEEDBACK || value === FeedbackEntryType.REVIEW) return value;
|
||||
return 'ALL';
|
||||
}
|
||||
|
||||
function parseStatusFilter(value: string | undefined): StatusFilter {
|
||||
const accepted: FeedbackStatus[] = ['NEW', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'RESOLVED'];
|
||||
if (accepted.includes(value as FeedbackStatus)) return value as FeedbackStatus;
|
||||
return 'ALL';
|
||||
}
|
||||
|
||||
function getSortIndicator(column: SortBy, activeSortBy: SortBy, activeSortDirection: SortDirection): string {
|
||||
if (column !== activeSortBy) return '↕';
|
||||
return activeSortDirection === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
|
||||
function getOrderBy(sortBy: SortBy, sortDirection: SortDirection): unknown {
|
||||
const createdAtTieBreaker = { createdAt: 'desc' as const };
|
||||
|
||||
if (sortBy === 'submittedAt') {
|
||||
return [{ createdAt: sortDirection }];
|
||||
}
|
||||
if (sortBy === 'type') {
|
||||
return [{ type: sortDirection }, createdAtTieBreaker];
|
||||
}
|
||||
if (sortBy === 'status') {
|
||||
return [{ status: sortDirection }, createdAtTieBreaker];
|
||||
}
|
||||
if (sortBy === 'rating') {
|
||||
return [{ rating: sortDirection }, createdAtTieBreaker];
|
||||
}
|
||||
if (sortBy === 'allowShowcase') {
|
||||
return [{ allowShowcase: sortDirection }, createdAtTieBreaker];
|
||||
}
|
||||
if (sortBy === 'showOnLanding') {
|
||||
return [{ showOnLanding: sortDirection }, createdAtTieBreaker];
|
||||
}
|
||||
|
||||
return [
|
||||
{ user: { name: sortDirection } },
|
||||
{ user: { email: sortDirection } },
|
||||
createdAtTieBreaker,
|
||||
];
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Feedback | Admin',
|
||||
};
|
||||
|
||||
export default async function AdminFeedbackPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
page?: string;
|
||||
sortBy?: string;
|
||||
sortDirection?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const rawPage = Number(params.page);
|
||||
const requestedPage = Number.isFinite(rawPage) ? Math.min(Math.max(1, rawPage), 500) : 1;
|
||||
const pageSize = 20;
|
||||
const sortBy = parseSortBy(params.sortBy);
|
||||
const sortDirection = parseSortDirection(params.sortDirection);
|
||||
const typeFilter = parseTypeFilter(params.type);
|
||||
const statusFilter = parseStatusFilter(params.status);
|
||||
|
||||
const where = {
|
||||
...(typeFilter !== 'ALL' ? { type: typeFilter } : {}),
|
||||
...(statusFilter !== 'ALL' ? { status: statusFilter } : {}),
|
||||
};
|
||||
const orderBy = getOrderBy(sortBy, sortDirection);
|
||||
|
||||
const userFeedbackDelegate = (db as unknown as {
|
||||
userFeedback?: {
|
||||
count: (args?: unknown) => Promise<number>;
|
||||
findMany: (args?: unknown) => Promise<AdminFeedbackEntry[]>;
|
||||
};
|
||||
}).userFeedback;
|
||||
|
||||
let totalEntries = 0;
|
||||
let page = requestedPage;
|
||||
let entries: AdminFeedbackEntry[] = [];
|
||||
|
||||
if (userFeedbackDelegate) {
|
||||
try {
|
||||
totalEntries = await userFeedbackDelegate.count({ where });
|
||||
const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize));
|
||||
page = Math.min(requestedPage, totalPages);
|
||||
const skip = (page - 1) * pageSize;
|
||||
entries = await userFeedbackDelegate.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
screenshots: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Unknown field `screenshots`')) {
|
||||
try {
|
||||
totalEntries = await userFeedbackDelegate.count({ where });
|
||||
const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize));
|
||||
page = Math.min(requestedPage, totalPages);
|
||||
const skip = (page - 1) * pageSize;
|
||||
const fallbackEntries = await userFeedbackDelegate.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
}) as Array<Omit<AdminFeedbackEntry, 'screenshots'> & { screenshots?: Array<{ id: string; url: string }> }>;
|
||||
|
||||
entries = fallbackEntries.map((entry) => ({
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
category: entry.category,
|
||||
title: entry.title,
|
||||
message: entry.message,
|
||||
screenshotUrl: entry.screenshotUrl,
|
||||
screenshots: Array.isArray(entry.screenshots) ? entry.screenshots : [],
|
||||
rating: entry.rating,
|
||||
status: entry.status,
|
||||
allowShowcase: entry.allowShowcase,
|
||||
showOnLanding: entry.showOnLanding,
|
||||
createdAt: entry.createdAt,
|
||||
user: entry.user,
|
||||
}));
|
||||
} catch (fallbackError) {
|
||||
console.error('Failed to fetch feedback entries (fallback):', fallbackError);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch feedback entries:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize));
|
||||
const paginatedEntries = entries;
|
||||
|
||||
const buildPageHref = (
|
||||
targetPage: number,
|
||||
targetSortBy: SortBy = sortBy,
|
||||
targetSortDirection: SortDirection = sortDirection,
|
||||
targetType: TypeFilter = typeFilter,
|
||||
targetStatus: StatusFilter = statusFilter
|
||||
): string => {
|
||||
const next = new URLSearchParams({
|
||||
page: String(targetPage),
|
||||
sortBy: targetSortBy,
|
||||
sortDirection: targetSortDirection,
|
||||
type: targetType,
|
||||
status: targetStatus,
|
||||
});
|
||||
return `/admin/feedback?${next.toString()}`;
|
||||
};
|
||||
|
||||
const buildSortHref = (column: SortBy): string => {
|
||||
const nextDirection: SortDirection =
|
||||
column === sortBy
|
||||
? sortDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: column === 'user'
|
||||
? 'asc'
|
||||
: 'desc';
|
||||
return buildPageHref(1, column, nextDirection);
|
||||
};
|
||||
|
||||
const buildFilterHref = (targetType: TypeFilter, targetStatus: StatusFilter): string =>
|
||||
buildPageHref(1, sortBy, sortDirection, targetType, targetStatus);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Feedback & Reviews</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant={typeFilter === 'ALL' ? 'default' : 'outline'} size="sm" asChild>
|
||||
<Link href={buildFilterHref('ALL', statusFilter)}>All Types</Link>
|
||||
</Button>
|
||||
<Button variant={typeFilter === 'FEEDBACK' ? 'default' : 'outline'} size="sm" asChild>
|
||||
<Link href={buildFilterHref('FEEDBACK', statusFilter)}>Feedback</Link>
|
||||
</Button>
|
||||
<Button variant={typeFilter === 'REVIEW' ? 'default' : 'outline'} size="sm" asChild>
|
||||
<Link href={buildFilterHref('REVIEW', statusFilter)}>Reviews</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant={statusFilter === 'ALL' ? 'default' : 'outline'} size="sm" asChild>
|
||||
<Link href={buildFilterHref(typeFilter, 'ALL')}>All Statuses</Link>
|
||||
</Button>
|
||||
{(['NEW', 'IN_REVIEW', 'APPROVED', 'REJECTED', 'RESOLVED'] as const).map((status) => (
|
||||
<Button key={status} variant={statusFilter === status ? 'default' : 'outline'} size="sm" asChild>
|
||||
<Link href={buildFilterHref(typeFilter, status)}>{status.replace('_', ' ')}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Submissions</CardTitle>
|
||||
<CardDescription>{totalEntries} submission(s) found.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Link href={buildSortHref('submittedAt')} className="inline-flex items-center gap-1 hover:underline">
|
||||
Submitted
|
||||
<span className="text-xs">{getSortIndicator('submittedAt', sortBy, sortDirection)}</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Link href={buildSortHref('user')} className="inline-flex items-center gap-1 hover:underline">
|
||||
User
|
||||
<span className="text-xs">{getSortIndicator('user', sortBy, sortDirection)}</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Link href={buildSortHref('type')} className="inline-flex items-center gap-1 hover:underline">
|
||||
Type
|
||||
<span className="text-xs">{getSortIndicator('type', sortBy, sortDirection)}</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead className="text-center">Screenshot</TableHead>
|
||||
<TableHead className="text-center">
|
||||
<Link href={buildSortHref('rating')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
||||
Rating
|
||||
<span className="text-xs">{getSortIndicator('rating', sortBy, sortDirection)}</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">
|
||||
<Link href={buildSortHref('status')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
||||
Status
|
||||
<span className="text-xs">{getSortIndicator('status', sortBy, sortDirection)}</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">
|
||||
<Link href={buildSortHref('allowShowcase')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
||||
Consent
|
||||
<span className="text-xs">{getSortIndicator('allowShowcase', sortBy, sortDirection)}</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">
|
||||
<Link href={buildSortHref('showOnLanding')} className="inline-flex items-center justify-center gap-1 hover:underline">
|
||||
Landing
|
||||
<span className="text-xs">{getSortIndicator('showOnLanding', sortBy, sortDirection)}</span>
|
||||
</Link>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedEntries.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="h-24 text-center">
|
||||
No submissions found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedEntries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{format(new Date(entry.createdAt), 'MMM dd, yyyy HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{entry.user.name || 'Anonymous'}</span>
|
||||
<span className="text-xs text-muted-foreground">{entry.user.email}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant="outline">{entry.type === 'FEEDBACK' ? 'Feedback' : 'Review'}</Badge>
|
||||
{entry.category && (
|
||||
<Badge variant="secondary" className="w-fit">
|
||||
{entry.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{entry.title}</TableCell>
|
||||
<TableCell className="max-w-[320px] truncate text-sm text-muted-foreground">{entry.message}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{(entry.screenshots.length > 0 || entry.screenshotUrl) ? (
|
||||
<Link href={`/admin/feedback/${entry.id}`} className="text-xs underline">
|
||||
{(entry.screenshots.length || (entry.screenshotUrl ? 1 : 0))} image
|
||||
{(entry.screenshots.length || (entry.screenshotUrl ? 1 : 0)) > 1 ? 's' : ''}
|
||||
</Link>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{entry.rating ?? '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{entry.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{entry.allowShowcase ? 'Yes' : 'No'}</TableCell>
|
||||
<TableCell className="text-center">{entry.showOnLanding ? 'Yes' : 'No'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/feedback/${entry.id}`}>Open</Link>
|
||||
</Button>
|
||||
<DeleteFeedbackButton
|
||||
feedbackId={entry.id}
|
||||
feedbackTitle={entry.title}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 py-4">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} asChild={page > 1}>
|
||||
{page > 1 ? <Link href={buildPageHref(page - 1)}>Previous</Link> : 'Previous'}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} asChild={page < totalPages}>
|
||||
{page < totalPages ? <Link href={buildPageHref(page + 1)}>Next</Link> : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
-1
@@ -2,7 +2,7 @@ import { redirect } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { Header } from '@/components/layout';
|
||||
import Link from 'next/link';
|
||||
import { LayoutDashboard, Users } from 'lucide-react';
|
||||
import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -30,6 +30,10 @@ export default async function AdminLayout({
|
||||
<Users className="h-4 w-4" />
|
||||
Users
|
||||
</Link>
|
||||
<Link href="/admin/feedback" className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50">
|
||||
<MessageSquareQuote className="h-4 w-4" />
|
||||
Feedback
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
{/* Desktop Nav */}
|
||||
@@ -50,6 +54,13 @@ export default async function AdminLayout({
|
||||
<Users className="h-4 w-4" />
|
||||
Users
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/feedback"
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<MessageSquareQuote className="h-4 w-4" />
|
||||
Feedback
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
+40
-1
@@ -4,7 +4,7 @@ import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getCachedBunnyStorageStats, getCachedTotalStorage } from '@/lib/admin-stats';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film } from 'lucide-react';
|
||||
import { Users, Folder, Video, MessageSquare, Mic, HardDrive, Image as ImageIcon, Film, MessageSquareQuote, Star } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Dashboard | OpenFrame',
|
||||
@@ -27,6 +27,10 @@ export default async function AdminDashboardPage() {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const userFeedbackDelegate = (db as unknown as {
|
||||
userFeedback?: { count: (args?: unknown) => Promise<number> };
|
||||
}).userFeedback;
|
||||
|
||||
// 1. Database Stats
|
||||
const [
|
||||
totalUsers,
|
||||
@@ -48,6 +52,23 @@ export default async function AdminDashboardPage() {
|
||||
}),
|
||||
]);
|
||||
|
||||
let totalFeedback = 0;
|
||||
let totalReviews = 0;
|
||||
if (userFeedbackDelegate) {
|
||||
try {
|
||||
[totalFeedback, totalReviews] = await Promise.all([
|
||||
userFeedbackDelegate.count({
|
||||
where: { type: 'FEEDBACK' },
|
||||
}),
|
||||
userFeedbackDelegate.count({
|
||||
where: { type: 'REVIEW' },
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch feedback stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Storage Stats (Cached)
|
||||
const [totalStorageBytes, bunnyStorageStats] = await Promise.all([
|
||||
getCachedTotalStorage(),
|
||||
@@ -117,6 +138,24 @@ export default async function AdminDashboardPage() {
|
||||
<div className="text-2xl font-bold">{totalImageComments}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Feedback Submissions</CardTitle>
|
||||
<MessageSquareQuote className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalFeedback}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Review Submissions</CardTitle>
|
||||
<Star className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalReviews}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cloudflare R2 Storage</CardTitle>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
|
||||
type RouteParams = { params: Promise<{ feedbackId: string }> };
|
||||
|
||||
function extractImageFilenameFromProxyUrl(url: string): string | null {
|
||||
const match = url.match(/^\/api\/upload\/image\/([0-9a-f-]+\.[a-z0-9]+)$/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// DELETE /api/admin/feedback/[feedbackId]
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'mutate');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) {
|
||||
return apiErrors.forbidden('Admin access required');
|
||||
}
|
||||
|
||||
const { feedbackId } = await params;
|
||||
const userFeedbackDelegate = (db as unknown as {
|
||||
userFeedback?: {
|
||||
findUnique: (args: unknown) => Promise<{
|
||||
id: string;
|
||||
screenshotUrl: string | null;
|
||||
screenshots?: Array<{ url: string }>;
|
||||
} | null>;
|
||||
delete: (args: { where: { id: string } }) => Promise<{ id: string }>;
|
||||
findFirst: (args: { where: { screenshotUrl: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
||||
};
|
||||
userFeedbackScreenshot?: {
|
||||
findFirst: (args: { where: { url: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
||||
};
|
||||
}).userFeedback;
|
||||
const userFeedbackScreenshotDelegate = (db as unknown as {
|
||||
userFeedbackScreenshot?: {
|
||||
findFirst: (args: { where: { url: string }; select: { id: true } }) => Promise<{ id: string } | null>;
|
||||
};
|
||||
}).userFeedbackScreenshot;
|
||||
|
||||
if (!userFeedbackDelegate) {
|
||||
return apiErrors.internalError('Feedback model is not available yet');
|
||||
}
|
||||
|
||||
let feedbackRecord = await userFeedbackDelegate.findUnique({
|
||||
where: { id: feedbackId },
|
||||
include: {
|
||||
screenshots: {
|
||||
select: { url: true },
|
||||
},
|
||||
},
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Unknown field `screenshots`')) return null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (!feedbackRecord) {
|
||||
feedbackRecord = await userFeedbackDelegate.findUnique({
|
||||
where: { id: feedbackId },
|
||||
});
|
||||
}
|
||||
|
||||
if (!feedbackRecord) {
|
||||
return apiErrors.notFound('Feedback');
|
||||
}
|
||||
|
||||
const mediaUrls = new Set<string>();
|
||||
if (feedbackRecord.screenshotUrl) mediaUrls.add(feedbackRecord.screenshotUrl);
|
||||
(feedbackRecord.screenshots ?? []).forEach((item) => {
|
||||
if (item.url) mediaUrls.add(item.url);
|
||||
});
|
||||
|
||||
await userFeedbackDelegate.delete({
|
||||
where: { id: feedbackId },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Array.from(mediaUrls).map(async (url) => {
|
||||
const filename = extractImageFilenameFromProxyUrl(url);
|
||||
if (!filename) return;
|
||||
|
||||
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] = await Promise.all([
|
||||
db.comment.findFirst({
|
||||
where: { imageUrl: url },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackDelegate.findFirst({
|
||||
where: { screenshotUrl: url },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackScreenshotDelegate
|
||||
? userFeedbackScreenshotDelegate.findFirst({
|
||||
where: { url },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (commentReferenced || feedbackReferenced || feedbackAttachmentReferenced) return;
|
||||
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: `images/${filename}`,
|
||||
})
|
||||
).catch(() => undefined);
|
||||
})
|
||||
);
|
||||
|
||||
return successResponse({ id: feedbackId });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Record to delete does not exist')) {
|
||||
return apiErrors.notFound('Feedback');
|
||||
}
|
||||
console.error('Error deleting feedback:', error);
|
||||
return apiErrors.internalError('Failed to delete feedback');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { FeedbackCategory, FeedbackEntryType } from '@prisma/client';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
|
||||
interface FeedbackPayload {
|
||||
type?: string;
|
||||
category?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
screenshotUrl?: string;
|
||||
screenshotUrls?: string[];
|
||||
rating?: number;
|
||||
allowShowcase?: boolean;
|
||||
}
|
||||
|
||||
// POST /api/feedback
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'feedback-submit');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized('You must be signed in to submit feedback');
|
||||
}
|
||||
|
||||
const body = (await request.json()) as FeedbackPayload;
|
||||
const type = body.type;
|
||||
const title = body.title?.trim() ?? '';
|
||||
const message = body.message?.trim() ?? '';
|
||||
const legacyScreenshotUrl = body.screenshotUrl?.trim() ?? null;
|
||||
const screenshotUrls = Array.isArray(body.screenshotUrls)
|
||||
? body.screenshotUrls
|
||||
.map((url) => (typeof url === 'string' ? url.trim() : ''))
|
||||
.filter((url) => !!url)
|
||||
: (legacyScreenshotUrl ? [legacyScreenshotUrl] : []);
|
||||
|
||||
if (type !== FeedbackEntryType.FEEDBACK && type !== FeedbackEntryType.REVIEW) {
|
||||
return apiErrors.badRequest('Invalid entry type');
|
||||
}
|
||||
|
||||
if (title.length < 3 || title.length > 120) {
|
||||
return apiErrors.badRequest('Title must be between 3 and 120 characters');
|
||||
}
|
||||
|
||||
if (message.length < 10 || message.length > 3000) {
|
||||
return apiErrors.badRequest('Message must be between 10 and 3000 characters');
|
||||
}
|
||||
|
||||
if (screenshotUrls.length > 5) {
|
||||
return apiErrors.badRequest('You can upload up to 5 screenshots');
|
||||
}
|
||||
|
||||
if (screenshotUrls.some((url) => !url.startsWith('/api/upload/image/'))) {
|
||||
return apiErrors.badRequest('Invalid screenshot URL(s)');
|
||||
}
|
||||
|
||||
if (type === FeedbackEntryType.FEEDBACK) {
|
||||
if (
|
||||
body.category !== FeedbackCategory.BUG &&
|
||||
body.category !== FeedbackCategory.FEATURE &&
|
||||
body.category !== FeedbackCategory.OTHER
|
||||
) {
|
||||
return apiErrors.badRequest('Feedback category is required');
|
||||
}
|
||||
}
|
||||
|
||||
if (type === FeedbackEntryType.REVIEW) {
|
||||
if (!Number.isInteger(body.rating) || (body.rating as number) < 1 || (body.rating as number) > 5) {
|
||||
return apiErrors.badRequest('Review rating must be between 1 and 5');
|
||||
}
|
||||
}
|
||||
|
||||
let usedLegacyCreatePath = false;
|
||||
let entry: { id: string; type: FeedbackEntryType; createdAt: Date };
|
||||
|
||||
try {
|
||||
entry = await db.userFeedback.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
type,
|
||||
category: type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
|
||||
title,
|
||||
message,
|
||||
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
|
||||
rating: type === FeedbackEntryType.REVIEW ? body.rating : null,
|
||||
allowShowcase: type === FeedbackEntryType.REVIEW ? !!body.allowShowcase : false,
|
||||
screenshots: type === FeedbackEntryType.FEEDBACK
|
||||
? {
|
||||
create: screenshotUrls.map((url) => ({ url })),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '';
|
||||
if (!errorMessage.includes('Unknown argument `screenshots`')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
usedLegacyCreatePath = true;
|
||||
entry = await db.userFeedback.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
type,
|
||||
category: type === FeedbackEntryType.FEEDBACK ? (body.category as FeedbackCategory) : null,
|
||||
title,
|
||||
message,
|
||||
screenshotUrl: type === FeedbackEntryType.FEEDBACK ? (screenshotUrls[0] ?? null) : null,
|
||||
rating: type === FeedbackEntryType.REVIEW ? body.rating : null,
|
||||
allowShowcase: type === FeedbackEntryType.REVIEW ? !!body.allowShowcase : false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (usedLegacyCreatePath && type === FeedbackEntryType.FEEDBACK && screenshotUrls.length > 1) {
|
||||
const screenshotDelegate = (db as unknown as {
|
||||
userFeedbackScreenshot?: {
|
||||
createMany: (args: { data: Array<{ feedbackId: string; url: string }> }) => Promise<unknown>;
|
||||
};
|
||||
}).userFeedbackScreenshot;
|
||||
|
||||
if (screenshotDelegate) {
|
||||
await screenshotDelegate.createMany({
|
||||
data: screenshotUrls.map((url) => ({
|
||||
feedbackId: entry.id,
|
||||
url,
|
||||
})),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return successResponse(entry, 201);
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback:', error);
|
||||
return apiErrors.internalError('Failed to submit feedback');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||
import {
|
||||
detectImageMime,
|
||||
getImageExtension,
|
||||
isAllowedImageType,
|
||||
normalizeImageMime,
|
||||
} from '@/lib/image-upload-validation';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead
|
||||
|
||||
// POST /api/feedback/upload
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'feedback-upload');
|
||||
if (limited) return limited;
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized('You must be signed in to upload screenshots');
|
||||
}
|
||||
|
||||
const contentLength = request.headers.get('content-length');
|
||||
if (!contentLength) {
|
||||
return apiErrors.badRequest('Missing Content-Length header');
|
||||
}
|
||||
const size = parseInt(contentLength, 10);
|
||||
if (Number.isNaN(size) || size <= 0) {
|
||||
return apiErrors.badRequest('Invalid Content-Length header');
|
||||
}
|
||||
if (size > MAX_MULTIPART_BODY_SIZE) {
|
||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll('image');
|
||||
if (files.length !== 1) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
const file = files[0];
|
||||
if (!(file instanceof File)) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||
}
|
||||
|
||||
const normalizedMime = normalizeImageMime(file.type);
|
||||
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
|
||||
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const detectedMime = detectImageMime(buffer);
|
||||
if (!detectedMime) {
|
||||
return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
|
||||
}
|
||||
|
||||
const ext = getImageExtension(detectedMime);
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const key = `images/${filename}`;
|
||||
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: detectedMime,
|
||||
})
|
||||
);
|
||||
|
||||
return successResponse({ url: `/api/upload/image/${filename}` }, 201);
|
||||
} catch (error) {
|
||||
console.error('Error uploading feedback screenshot:', error);
|
||||
return apiErrors.internalError('Failed to upload screenshot');
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,12 @@ const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
function getContentType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
return CONTENT_TYPE_MAP[ext] || 'image/jpeg';
|
||||
return CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
@@ -47,11 +46,28 @@ export async function GET(
|
||||
|
||||
const lastModified = headResponse.LastModified;
|
||||
if (lastModified && Date.now() - lastModified.getTime() > UNATTACHED_UPLOAD_TTL_MS) {
|
||||
const referenced = await db.comment.findFirst({
|
||||
where: { imageUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!referenced) {
|
||||
const userFeedbackScreenshotDelegate = (db as unknown as {
|
||||
userFeedbackScreenshot?: {
|
||||
findFirst: (args?: unknown) => Promise<{ id: string } | null>;
|
||||
};
|
||||
}).userFeedbackScreenshot;
|
||||
const [commentReferenced, feedbackReferenced, feedbackAttachmentReferenced] = await Promise.all([
|
||||
db.comment.findFirst({
|
||||
where: { imageUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
}),
|
||||
db.userFeedback.findFirst({
|
||||
where: { screenshotUrl: mediaUrl },
|
||||
select: { id: true },
|
||||
}),
|
||||
userFeedbackScreenshotDelegate
|
||||
? userFeedbackScreenshotDelegate.findFirst({
|
||||
where: { url: mediaUrl },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
if (!commentReferenced && !feedbackReferenced && !feedbackAttachmentReferenced) {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
@@ -62,7 +78,7 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = headResponse.ContentType || getContentType(filename);
|
||||
const contentType = getContentType(filename);
|
||||
|
||||
const objectResponse = await r2Client.send(
|
||||
new GetObjectCommand({
|
||||
@@ -94,6 +110,8 @@ export async function GET(
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Security-Policy': "default-src 'none'; sandbox",
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -8,6 +8,12 @@ import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import {
|
||||
detectImageMime,
|
||||
getImageExtension,
|
||||
isAllowedImageType,
|
||||
normalizeImageMime,
|
||||
} from '@/lib/image-upload-validation';
|
||||
import {
|
||||
deriveGuestUploadContext,
|
||||
enforceGuestUploadQuota,
|
||||
@@ -15,22 +21,21 @@ import {
|
||||
} from '@/lib/guest-upload-token';
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
];
|
||||
const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check Content-Length header BEFORE loading the file
|
||||
const contentLength = request.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
const fileSize = parseInt(contentLength, 10);
|
||||
if (isNaN(fileSize) || fileSize > MAX_FILE_SIZE) {
|
||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||
}
|
||||
if (!contentLength) {
|
||||
return apiErrors.badRequest('Missing Content-Length header');
|
||||
}
|
||||
const bodySize = parseInt(contentLength, 10);
|
||||
if (isNaN(bodySize) || bodySize <= 0) {
|
||||
return apiErrors.badRequest('Invalid Content-Length header');
|
||||
}
|
||||
if (bodySize > MAX_MULTIPART_BODY_SIZE) {
|
||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
@@ -40,11 +45,15 @@ export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image') as File | null;
|
||||
const files = formData.getAll('image');
|
||||
if (files.length !== 1) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
const file = files[0];
|
||||
const videoId = formData.get('videoId');
|
||||
const uploadToken = formData.get('uploadToken');
|
||||
|
||||
if (!file) {
|
||||
if (!(file instanceof File)) {
|
||||
return apiErrors.badRequest('No image file provided');
|
||||
}
|
||||
if (typeof videoId !== 'string' || !videoId.trim()) {
|
||||
@@ -107,19 +116,23 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Check content type
|
||||
const contentType = file.type;
|
||||
if (!ALLOWED_TYPES.includes(contentType)) {
|
||||
return apiErrors.badRequest(`Unsupported image format: ${contentType}`);
|
||||
const normalizedMime = normalizeImageMime(file.type);
|
||||
if (normalizedMime && !isAllowedImageType(normalizedMime)) {
|
||||
return apiErrors.badRequest(`Unsupported image format: ${file.type}`);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = contentType.split('/')[1] || 'jpeg';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const key = `images/${filename}`;
|
||||
|
||||
// Convert to buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const detectedMime = detectImageMime(buffer);
|
||||
if (!detectedMime) {
|
||||
return apiErrors.badRequest('Uploaded file content does not match an allowed image type');
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = getImageExtension(detectedMime);
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const key = `images/${filename}`;
|
||||
|
||||
// Upload to R2
|
||||
await r2Client.send(
|
||||
@@ -127,7 +140,7 @@ export async function POST(request: NextRequest) {
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
ContentType: detectedMime,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface DeleteFeedbackButtonProps {
|
||||
feedbackId: string;
|
||||
feedbackTitle: string;
|
||||
redirectTo?: string;
|
||||
size?: 'default' | 'sm';
|
||||
variant?: 'outline' | 'destructive';
|
||||
}
|
||||
|
||||
export function DeleteFeedbackButton({
|
||||
feedbackId,
|
||||
feedbackTitle,
|
||||
redirectTo,
|
||||
size = 'sm',
|
||||
variant = 'outline',
|
||||
}: DeleteFeedbackButtonProps) {
|
||||
const router = useRouter();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/feedback/${feedbackId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setError((data as { error?: string }).error || 'Failed to delete feedback');
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirectTo) {
|
||||
router.push(redirectTo);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to delete feedback');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant={variant} size={size} disabled={isDeleting}>
|
||||
{isDeleting ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Trash2 className="mr-1.5 h-4 w-4" />}
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this feedback?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{feedbackTitle}". This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
User,
|
||||
Menu,
|
||||
Keyboard,
|
||||
LayoutDashboard
|
||||
LayoutDashboard,
|
||||
MessageSquareQuote,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -151,7 +152,21 @@ export function Header({ user }: HeaderProps) {
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
|
||||
{user && (
|
||||
<Button asChild variant="outline" size="sm" className="hidden sm:inline-flex">
|
||||
<Link href="/feedback">
|
||||
<MessageSquareQuote className="h-4 w-4 mr-1.5" />
|
||||
Feedback
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{user && (
|
||||
<Button asChild variant="ghost" size="icon" className="sm:hidden" aria-label="Feedback and reviews">
|
||||
<Link href="/feedback">
|
||||
<MessageSquareQuote className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
|
||||
export type AllowedImageMimeType = (typeof ALLOWED_IMAGE_MIME_TYPES)[number];
|
||||
|
||||
const EXT_BY_MIME: Record<AllowedImageMimeType, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
export function normalizeImageMime(value: string): string {
|
||||
if (value === 'image/jpg' || value === 'image/pjpeg') return 'image/jpeg';
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isAllowedImageType(value: string): value is AllowedImageMimeType {
|
||||
return (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function detectImageMime(buffer: Uint8Array): AllowedImageMimeType | null {
|
||||
// JPEG
|
||||
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
// PNG
|
||||
if (
|
||||
buffer.length >= 8
|
||||
&& buffer[0] === 0x89
|
||||
&& buffer[1] === 0x50
|
||||
&& buffer[2] === 0x4e
|
||||
&& buffer[3] === 0x47
|
||||
&& buffer[4] === 0x0d
|
||||
&& buffer[5] === 0x0a
|
||||
&& buffer[6] === 0x1a
|
||||
&& buffer[7] === 0x0a
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
// GIF87a/GIF89a
|
||||
if (
|
||||
buffer.length >= 6
|
||||
&& buffer[0] === 0x47
|
||||
&& buffer[1] === 0x49
|
||||
&& buffer[2] === 0x46
|
||||
&& buffer[3] === 0x38
|
||||
&& (buffer[4] === 0x37 || buffer[4] === 0x39)
|
||||
&& buffer[5] === 0x61
|
||||
) {
|
||||
return 'image/gif';
|
||||
}
|
||||
// WEBP: "RIFF"...."WEBP"
|
||||
if (
|
||||
buffer.length >= 12
|
||||
&& buffer[0] === 0x52
|
||||
&& buffer[1] === 0x49
|
||||
&& buffer[2] === 0x46
|
||||
&& buffer[3] === 0x46
|
||||
&& buffer[8] === 0x57
|
||||
&& buffer[9] === 0x45
|
||||
&& buffer[10] === 0x42
|
||||
&& buffer[11] === 0x50
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getImageExtension(mime: AllowedImageMimeType): string {
|
||||
return EXT_BY_MIME[mime];
|
||||
}
|
||||
|
||||
export function firstBytesHex(buffer: Uint8Array, length = 16): string {
|
||||
return Array.from(buffer.slice(0, length))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
}
|
||||
@@ -22,7 +22,10 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
|
||||
// Content creation — moderate limits
|
||||
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
|
||||
'image-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
'feedback-submit': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
|
||||
'feedback-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
|
||||
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
|
||||
@@ -32,10 +32,30 @@ model User {
|
||||
projectMemberships ProjectMember[]
|
||||
notificationSetting NotificationSetting?
|
||||
watchProgress WatchProgress[]
|
||||
feedbackEntries UserFeedback[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
enum FeedbackEntryType {
|
||||
FEEDBACK
|
||||
REVIEW
|
||||
}
|
||||
|
||||
enum FeedbackCategory {
|
||||
BUG
|
||||
FEATURE
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum FeedbackStatus {
|
||||
NEW
|
||||
IN_REVIEW
|
||||
APPROVED
|
||||
REJECTED
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
enum DownloadEgressSource {
|
||||
ORIGINAL
|
||||
COMPRESSED
|
||||
@@ -392,6 +412,41 @@ model ShareLink {
|
||||
@@map("share_links")
|
||||
}
|
||||
|
||||
model UserFeedback {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
type FeedbackEntryType
|
||||
category FeedbackCategory?
|
||||
title String
|
||||
message String @db.Text
|
||||
screenshotUrl String?
|
||||
screenshots UserFeedbackScreenshot[]
|
||||
rating Int?
|
||||
status FeedbackStatus @default(NEW)
|
||||
allowShowcase Boolean @default(false)
|
||||
showOnLanding Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([type, createdAt(sort: Desc)])
|
||||
@@index([status, createdAt(sort: Desc)])
|
||||
@@index([showOnLanding, createdAt(sort: Desc)])
|
||||
@@map("user_feedback")
|
||||
}
|
||||
|
||||
model UserFeedbackScreenshot {
|
||||
id String @id @default(cuid())
|
||||
feedbackId String
|
||||
feedback UserFeedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
|
||||
url String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([feedbackId, createdAt(sort: Desc)])
|
||||
@@map("user_feedback_screenshots")
|
||||
}
|
||||
|
||||
enum SharePermission {
|
||||
VIEW // Can only view
|
||||
COMMENT // Can view and comment
|
||||
|
||||
Reference in New Issue
Block a user