Merge pull request #2539 from homarr-labs/feat/admin-comment

This commit is contained in:
Thomas Camlong
2025-11-23 13:54:35 +01:00
committed by GitHub
6 changed files with 354 additions and 34 deletions

View File

@@ -0,0 +1,229 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* Hook to send email notification when a submission is updated.
* Sends email to the submission creator when status changes.
*
* Sources:
* - https://pocketbase.io/docs/js-event-hooks/#onrecordupdaterequest
* - https://pocketbase.io/docs/js-logging/
* - https://pocketbase.io/docs/js-sending-emails/
*/
onRecordUpdate((e) => {
const logger = e.app.logger().withGroup("submission_update_email");
const record = e.record;
const recordId = record.id;
const submissionName = record.get("name");
const newStatus = record.get("status");
// 1. Fetch the old record from the database to compare status.
// We do this BEFORE e.next() because the DB still has the old data.
let oldStatus = null;
try {
// Note: app.dao() is deprecated/removed in newer versions, use app.findRecordById directly.
const oldRecord = e.app.findRecordById("submissions", recordId);
oldStatus = oldRecord.get("status");
} catch (err) {
// It's possible the record doesn't exist or something else went wrong.
// If we can't fetch the old status, we can't reliably determine if it changed.
logger.warn("Could not fetch old record status", "id", recordId, "error", err);
}
// 2. Proceed with the update
e.next();
// 3. Post-update logic: Send email if status changed
if (oldStatus && oldStatus !== newStatus) {
logger.info("Submission status changed",
"id", recordId,
"old_status", oldStatus,
"new_status", newStatus
);
const createdById = record.get("created_by");
if (!createdById) {
logger.warn("Submission has no created_by user", "id", recordId);
return;
}
// Fetch the user to get the email.
// 'created_by' points to a 'users' (or auth) record.
let userRecord;
try {
try {
userRecord = e.app.findRecordById("users", createdById);
} catch (err1) {
// Fallback for other auth collections if needed, though usually it's 'users'
logger.debug("User not found in 'users', trying '_pb_users_auth_'", "error", err1);
userRecord = e.app.findRecordById("_pb_users_auth_", createdById);
}
} catch (err) {
logger.error("Could not find user for submission", "created_by", createdById, "error", err);
return;
}
const userEmail = userRecord.get("email");
const userName = userRecord.get("username") || "User";
if (!userEmail) {
logger.warn("User has no email", "user_id", createdById);
return;
}
// Fetch info about the person who updated it (if available in context, but hooks run server side)
// Since we don't have direct access to the 'operator' in this hook easily without request context,
// we'll check if 'approved_by' field was updated or exists on the record.
let reviewerName = "The Dashboard Icons Team";
const approvedById = record.get("approved_by");
if (approvedById) {
try {
const reviewerRecord = e.app.findRecordById("users", approvedById);
reviewerName = reviewerRecord.get("username") || reviewerRecord.get("email") || "Admin";
} catch (_) {
// If we can't find the reviewer, we'll just use the default
}
}
// Prepare email content variables
const adminComment = record.get("admin_comment") || "";
const dashboardLink = "https://dashboardicons.com/dashboard";
const submissionIdShort = recordId.substring(0, 8);
// Styling constants
const primaryColor = "#2563eb"; // Blue-600
const successColor = "#16a34a"; // Green-600
const errorColor = "#dc2626"; // Red-600
const neutralColor = "#4b5563"; // Gray-600
const lightBg = "#f3f4f6"; // Gray-100
// Email Template Helpers
const header = `
<div style="background-color: #111827; padding: 20px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-family: system-ui, -apple-system, sans-serif; font-size: 24px;">Dashboard Icons</h1>
</div>
<div style="padding: 30px; background-color: #ffffff; border: 1px solid #e5e7eb; border-top: none;">
`;
const footer = `
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #6b7280; text-align: center;">
<p>Submission ID: <code>${recordId}</code> (Short: ${submissionIdShort})</p>
<p>
<a href="${dashboardLink}" style="color: ${primaryColor}; text-decoration: none;">Visit Dashboard</a> |
<a href="https://dashboardicons.com" style="color: ${primaryColor}; text-decoration: none;">Home</a>
</p>
<p>&copy; ${new Date().getFullYear()} Dashboard Icons. All rights reserved.</p>
</div>
</div>
`;
let subject = "";
let contentBody = "";
// Construct email based on status
if (newStatus === "approved") {
subject = `dashboardicons.com - Submission approved: "${submissionName}"`;
contentBody = `
<h2 style="color: ${successColor}; margin-top: 0;">Congratulations!</h2>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">Hello ${userName},</p>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
🥳 Great news! Your icon submission "<strong>${submissionName}</strong>" has been approved by <strong>${reviewerName}</strong>.
</p>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
Thank you for your contribution to the icon collection. Your work helps make Dashboard Icons better for everyone.
</p>
<div style="margin: 25px 0; text-align: center;">
<a href="${dashboardLink}" style="background-color: ${successColor}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">View in Dashboard</a>
</div>
`;
} else if (newStatus === "rejected") {
subject = `dashboardicons - Submission rejected: "${submissionName}"`;
contentBody = `
<h2 style="color: ${errorColor}; margin-top: 0;">Submission Rejected</h2>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">Hello ${userName},</p>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
We're sorry, but your icon submission "<strong>${submissionName}</strong>" has been rejected by <strong>${reviewerName}</strong>.
</p>
${adminComment ? `
<div style="margin: 20px 0; padding: 15px; background-color: #fef2f2; border-left: 4px solid ${errorColor}; border-radius: 4px;">
<h3 style="margin-top: 0; color: ${errorColor}; font-size: 14px; text-transform: uppercase;">Reason for Rejection</h3>
<p style="margin-bottom: 0; white-space: pre-wrap; color: #7f1d1d;">${adminComment}</p>
</div>
` : ""}
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
Don't be discouraged! You can review the feedback and submit a new version or try a different icon.
</p>
<div style="margin: 25px 0; text-align: center;">
<a href="${dashboardLink}" style="background-color: ${neutralColor}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Manage Submissions</a>
</div>
`;
} else if (newStatus === "added_to_collection") {
subject = `dashboardicons - Submission published: "${submissionName}"`;
contentBody = `
<h2 style="color: ${primaryColor}; margin-top: 0;">Icon Published!</h2>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">Hello ${userName},</p>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
🚀 Fantastic! Your icon "<strong>${submissionName}</strong>" has been added to the official collection.
</p>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
It is now available for the community to use and fully added to the collection. We appreciate your quality contribution.
</p>
<div style="margin: 25px 0; text-align: center;">
<a href="${dashboardLink}" style="background-color: ${primaryColor}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">See Your Icon</a>
</div>
`;
} else if (newStatus === "pending") {
subject = `dashboardicons - Submission pending review: "${submissionName}"`;
contentBody = `
<h2 style="color: ${neutralColor}; margin-top: 0;">Submission Received</h2>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">Hello ${userName},</p>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
Your submission "<strong>${submissionName}</strong>" is now <strong>Pending Review</strong>.
</p>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
An admin will review your icon shortly to ensure it meets our quality standards. You will be notified once the review is complete.
</p>
<div style="margin: 25px 0; text-align: center;">
<a href="${dashboardLink}" style="background-color: ${neutralColor}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">View Status</a>
</div>
`;
} else {
// Generic update
subject = `Update on submission "${submissionName}"`;
contentBody = `
<h2 style="color: ${neutralColor}; margin-top: 0;">Status Update</h2>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">Hello ${userName},</p>
<p style="font-size: 16px; line-height: 1.5; color: #374151;">
Your submission "<strong>${submissionName}</strong>" status has changed to: <strong>${newStatus}</strong>.
</p>
${adminComment ? `
<div style="margin: 20px 0; padding: 15px; background-color: ${lightBg}; border-left: 4px solid ${neutralColor}; border-radius: 4px;">
<h3 style="margin-top: 0; color: ${neutralColor}; font-size: 14px; text-transform: uppercase;">Admin Comment</h3>
<p style="margin-bottom: 0; white-space: pre-wrap; color: #1f2937;">${adminComment}</p>
</div>
` : ""}
<div style="margin: 25px 0; text-align: center;">
<a href="${dashboardLink}" style="background-color: ${neutralColor}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Go to Dashboard</a>
</div>
`;
}
const fullHtml = `${header}${contentBody}${footer}`;
try {
const message = new MailerMessage({
from: {
address: e.app.settings().meta.senderAddress || "noreply@example.com",
name: e.app.settings().meta.senderName || "Dashboard Icons",
},
to: [{ address: userEmail }],
subject: subject,
html: fullHtml,
});
e.app.newMailClient().send(message);
logger.info("Sent email notification", "to", userEmail, "submission_id", recordId);
} catch (err) {
logger.error("Failed to send email", "error", err);
}
}
}, "submissions");

View File

@@ -1,12 +1,23 @@
"use client"
import { AlertCircle, RefreshCw } from "lucide-react"
import * as React from "react"
import { ExperimentalWarning } from "@/components/experimental-warning"
import { SubmissionsDataTable } from "@/components/submissions-data-table"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { useApproveSubmission, useAuth, useRejectSubmission, useSubmissions } from "@/hooks/use-submissions"
export default function DashboardPage() {
@@ -20,6 +31,11 @@ export default function DashboardPage() {
const approveMutation = useApproveSubmission()
const rejectMutation = useRejectSubmission()
// Rejection dialog state
const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
const [rejectingSubmissionId, setRejectingSubmissionId] = React.useState<string | null>(null)
const [adminComment, setAdminComment] = React.useState("")
const isLoading = authLoading || submissionsLoading
const isAuthenticated = auth?.isAuthenticated ?? false
const isAdmin = auth?.isAdmin ?? false
@@ -30,7 +46,33 @@ export default function DashboardPage() {
}
const handleReject = (submissionId: string) => {
rejectMutation.mutate(submissionId)
setRejectingSubmissionId(submissionId)
setAdminComment("")
setRejectDialogOpen(true)
}
const handleRejectSubmit = () => {
if (rejectingSubmissionId) {
rejectMutation.mutate(
{
submissionId: rejectingSubmissionId,
adminComment: adminComment.trim() || undefined,
},
{
onSuccess: () => {
setRejectDialogOpen(false)
setRejectingSubmissionId(null)
setAdminComment("")
},
},
)
}
}
const handleRejectDialogClose = () => {
setRejectDialogOpen(false)
setRejectingSubmissionId(null)
setAdminComment("")
}
// Not authenticated
@@ -104,29 +146,62 @@ export default function DashboardPage() {
// Success state
return (
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<ExperimentalWarning message="The submissions dashboard is currently in an experimentation phase. Submissions will not be reviewed or processed at this time. We're gathering feedback to improve the experience." />
<Card className="bg-background/50 border-none shadow-lg">
<CardHeader>
<CardTitle>Submissions Dashboard</CardTitle>
<CardDescription>
{isAdmin
? "Review and manage all icon submissions. Click on a row to see details."
: "View your icon submissions and track their status."}
</CardDescription>
</CardHeader>
<CardContent>
<SubmissionsDataTable
data={submissions}
isAdmin={isAdmin}
currentUserId={currentUserId}
onApprove={handleApprove}
onReject={handleReject}
isApproving={approveMutation.isPending}
isRejecting={rejectMutation.isPending}
/>
</CardContent>
</Card>
</main>
<>
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<ExperimentalWarning message="The submissions dashboard is currently in an experimentation phase. Submissions will not be reviewed or processed at this time. We're gathering feedback to improve the experience." />
<Card className="bg-background/50 border-none shadow-lg">
<CardHeader>
<CardTitle>Submissions Dashboard</CardTitle>
<CardDescription>
{isAdmin
? "Review and manage all icon submissions. Click on a row to see details."
: "View your icon submissions and track their status."}
</CardDescription>
</CardHeader>
<CardContent>
<SubmissionsDataTable
data={submissions}
isAdmin={isAdmin}
currentUserId={currentUserId}
onApprove={handleApprove}
onReject={handleReject}
isApproving={approveMutation.isPending}
isRejecting={rejectMutation.isPending}
/>
</CardContent>
</Card>
</main>
<Dialog open={rejectDialogOpen} onOpenChange={handleRejectDialogClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Submission</DialogTitle>
<DialogDescription>
Please provide a reason for rejecting this submission. This comment will be visible to the submitter.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="admin-comment">Admin Comment</Label>
<Textarea
id="admin-comment"
placeholder="Enter rejection reason..."
value={adminComment}
onChange={(e) => setAdminComment(e.target.value)}
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleRejectDialogClose} disabled={rejectMutation.isPending}>
Cancel
</Button>
<Button variant="destructive" onClick={handleRejectSubmit} disabled={rejectMutation.isPending}>
{rejectMutation.isPending ? "Rejecting..." : "Reject Submission"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -350,14 +350,11 @@ export function AdvancedIconSubmissionFormTanStack() {
<AlertDialogDescription>
This icon submission form is a work-in-progress and is currently
in an experimentation phase. If you want a faster review, please
submit your icon to the{" "}
<Link
className="text-primary hover:underline"
href={REPO_PATH}
target="_blank"
>
github repository
</Link>{" "}
submit your icon to the dashboard icons{" "}
<a href={REPO_PATH} target="_blank">
{" "}
github repository{" "}
</a>{" "}
instead.
<br />
<br />

View File

@@ -8,6 +8,7 @@ import {
Eye,
FileType,
FolderOpen,
MessageSquare,
Palette,
Tag,
User as UserIcon,
@@ -17,6 +18,7 @@ import Image from "next/image";
import Link from "next/link";
import { IconCard } from "@/components/icon-card";
import { MagicCard } from "@/components/magicui/magic-card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -409,6 +411,20 @@ export function SubmissionDetails({
</div>
</div>
{submission.admin_comment?.trim() && (
<Alert
variant={
submission.status === "rejected" ? "destructive" : "default"
}
>
<MessageSquare className="h-4 w-4" />
<AlertTitle>Admin Comment</AlertTitle>
<AlertDescription className="mt-2 whitespace-pre-wrap">
{submission.admin_comment}
</AlertDescription>
</Alert>
)}
<Separator />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">

View File

@@ -74,12 +74,13 @@ export function useRejectSubmission() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (submissionId: string) => {
mutationFn: async ({ submissionId, adminComment }: { submissionId: string; adminComment?: string }) => {
return await pb.collection("submissions").update(
submissionId,
{
status: "rejected",
approved_by: pb.authStore.record?.id || "",
admin_comment: adminComment || "",
},
{
requestKey: null,

View File

@@ -36,6 +36,8 @@ export interface Submission {
}
created: string
updated: string
admin_comment: string,
description: string,
}
export interface CommunityGallery {