diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c5c36a87 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,265 @@ +# AGENTS.md + +> Guidelines for AI coding agents working on this project. + +## Project Overview + +**prompts.chat** is a social platform for AI prompts built with Next.js 16. It allows users to share, discover, and collect prompts from the community. The project is open source and can be self-hosted with customizable branding, themes, and authentication. + +### Tech Stack + +- **Framework:** Next.js 16.0.7 (App Router) with React 19.2 +- **Language:** TypeScript 5 +- **Database:** PostgreSQL with Prisma ORM 6.19 +- **Authentication:** NextAuth.js 5 (beta) with pluggable providers (credentials, GitHub, Google, Azure) +- **Styling:** Tailwind CSS 4 with Radix UI primitives +- **UI Components:** shadcn/ui pattern (components in `src/components/ui/`) +- **Internationalization:** next-intl with 11 supported locales +- **Icons:** Lucide React +- **Forms:** React Hook Form with Zod validation + +## Project Structure + +``` +/ +├── prisma/ # Database schema and migrations +│ ├── schema.prisma # Prisma schema definition +│ ├── migrations/ # Database migrations +│ └── seed.ts # Database seeding script +├── public/ # Static assets (logos, favicon) +├── messages/ # i18n translation files (en.json, es.json, etc.) +├── src/ +│ ├── app/ # Next.js App Router pages +│ │ ├── (auth)/ # Auth pages (login, register) +│ │ ├── [username]/ # User profile pages +│ │ ├── admin/ # Admin dashboard +│ │ ├── api/ # API routes +│ │ ├── categories/ # Category pages +│ │ ├── prompts/ # Prompt CRUD pages +│ │ ├── feed/ # User feed +│ │ ├── discover/ # Discovery page +│ │ ├── settings/ # User settings +│ │ └── tags/ # Tag pages +│ ├── components/ # React components +│ │ ├── admin/ # Admin-specific components +│ │ ├── auth/ # Authentication components +│ │ ├── categories/ # Category components +│ │ ├── layout/ # Layout components (header, etc.) +│ │ ├── prompts/ # Prompt-related components +│ │ ├── providers/ # React context providers +│ │ ├── settings/ # Settings components +│ │ └── ui/ # shadcn/ui base components +│ ├── lib/ # Utility libraries +│ │ ├── ai/ # AI/OpenAI integration +│ │ ├── auth/ # NextAuth configuration +│ │ ├── config/ # Config type definitions +│ │ ├── i18n/ # Internationalization setup +│ │ ├── plugins/ # Plugin system (auth, storage) +│ │ ├── db.ts # Prisma client instance +│ │ └── utils.ts # Utility functions (cn) +│ └── i18n/ # i18n request handler +├── prompts.config.ts # Main application configuration +├── prompts.csv # Community prompts data source +└── package.json # Dependencies and scripts +``` + +## Commands + +```bash +# Development +npm run dev # Start development server (localhost:3000) +npm run build # Build for production (runs prisma generate first) +npm run start # Start production server +npm run lint # Run ESLint + +# Database +npm run db:generate # Generate Prisma client +npm run db:migrate # Run database migrations +npm run db:push # Push schema changes to database +npm run db:studio # Open Prisma Studio +npm run db:seed # Seed database with initial data + +# Type checking +npx tsc --noEmit # Check TypeScript types without emitting +``` + +## Code Style Guidelines + +### TypeScript + +- Use TypeScript strict mode +- Prefer explicit types over `any` +- Use `interface` for object shapes, `type` for unions/intersections +- Functions: `camelCase` (e.g., `getUserData`, `handleSubmit`) +- Components: `PascalCase` (e.g., `PromptCard`, `AuthContent`) +- Constants: `UPPER_SNAKE_CASE` for true constants +- Files: `kebab-case.tsx` for components, `camelCase.ts` for utilities + +### React/Next.js + +- Use React Server Components by default +- Add `"use client"` directive only when client interactivity is needed +- Prefer server actions over API routes for mutations +- Use `next-intl` for all user-facing strings (never hardcode text) +- Import translations with `useTranslations()` or `getTranslations()` + +### Component Pattern + +```tsx +// Client component example +"use client"; + +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; + +interface MyComponentProps { + title: string; + onAction: () => void; +} + +export function MyComponent({ title, onAction }: MyComponentProps) { + const t = useTranslations("namespace"); + + return ( +
+

{title}

+ +
+ ); +} +``` + +### Styling + +- Use Tailwind CSS utility classes +- Follow mobile-first responsive design (`sm:`, `md:`, `lg:` breakpoints) +- Use `cn()` utility from `@/lib/utils` for conditional classes +- Prefer Radix UI primitives via shadcn/ui components +- Keep component styling scoped and composable + +### Database + +- Use Prisma Client from `@/lib/db` +- Always include proper `select` or `include` for relations +- Use transactions for multi-step operations +- Add indexes for frequently queried fields + +## Configuration + +The main configuration file is `prompts.config.ts`: + +- **branding:** Logo, name, and description +- **theme:** Colors, border radius, UI variant +- **auth:** Authentication providers array (credentials, github, google, azure) +- **i18n:** Supported locales and default locale +- **features:** Feature flags (privatePrompts, changeRequests, categories, tags, aiSearch) +- **homepage:** Homepage customization and sponsors + +## Plugin System + +Authentication and storage use a plugin architecture: + +### Auth Plugins (`src/lib/plugins/auth/`) +- `credentials.ts` - Email/password authentication +- `github.ts` - GitHub OAuth +- `google.ts` - Google OAuth +- `azure.ts` - Microsoft Entra ID + +### Storage Plugins (`src/lib/plugins/storage/`) +- `url.ts` - URL-based media (default) +- `s3.ts` - AWS S3 storage + +## Internationalization + +- Translation files are in `messages/{locale}.json` +- Currently supported: en, tr, es, zh, ja, ar, pt, fr, de, ko, it +- Add new locales to `prompts.config.ts` i18n.locales array +- Create corresponding translation file in `messages/` +- Add language to selector in `src/components/layout/header.tsx` + +## Key Files + +| File | Purpose | +|------|---------| +| `prompts.config.ts` | Main app configuration | +| `prisma/schema.prisma` | Database schema | +| `src/lib/auth/index.ts` | NextAuth configuration | +| `src/lib/db.ts` | Prisma client singleton | +| `src/app/layout.tsx` | Root layout with providers | +| `src/components/ui/` | Base UI components (shadcn) | + +## Boundaries + +### Always Do +- Run `npm run lint` before committing +- Use existing UI components from `src/components/ui/` +- Add translations for all user-facing text +- Follow existing code patterns and file structure +- Use TypeScript strict types + +### Ask First +- Database schema changes (require migrations) +- Adding new dependencies +- Modifying authentication flow +- Changes to `prompts.config.ts` structure + +### Never Do +- Commit secrets or API keys (use `.env`) +- Modify `node_modules/` or generated files +- Delete existing translations +- Remove or weaken TypeScript types +- Hardcode user-facing strings (use i18n) + +## Environment Variables + +Required in `.env`: +``` +DATABASE_URL= # PostgreSQL connection string +AUTH_SECRET= # NextAuth secret key +``` + +Optional OAuth (if using those providers): +``` +AUTH_GITHUB_ID= +AUTH_GITHUB_SECRET= +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= +AUTH_AZURE_AD_CLIENT_ID= +AUTH_AZURE_AD_CLIENT_SECRET= +AUTH_AZURE_AD_ISSUER= +``` + +Optional features: +``` +OPENAI_API_KEY= # For AI-powered semantic search +``` + +## Testing + +Currently no automated tests. When implementing: +- Place tests adjacent to source files or in `__tests__/` directories +- Use descriptive test names +- Mock external services (database, OAuth) + +## Common Tasks + +### Adding a new page +1. Create route in `src/app/{route}/page.tsx` +2. Use server component for data fetching +3. Add translations to `messages/*.json` + +### Adding a new component +1. Create in appropriate `src/components/{category}/` folder +2. Export from component file (no barrel exports needed) +3. Follow existing component patterns + +### Adding a new API route +1. Create in `src/app/api/{route}/route.ts` +2. Export appropriate HTTP method handlers (GET, POST, etc.) +3. Use Zod for request validation +4. Return proper JSON responses with status codes + +### Modifying database schema +1. Update `prisma/schema.prisma` +2. Run `npm run db:migrate` to create migration +3. Update related TypeScript types if needed diff --git a/messages/ar.json b/messages/ar.json index 7213df10..64bac8d8 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -259,7 +259,29 @@ "categories": "التصنيفات", "tags": "الوسوم", "webhooks": "Webhooks", - "import": "استيراد أوامر المجتمع" + "prompts": "الأوامر", + "reports": "البلاغات" + }, + "reports": { + "title": "إدارة البلاغات", + "description": "مراجعة وإدارة الأوامر المبلغ عنها", + "prompt": "الأمر", + "reason": "السبب", + "reportedBy": "المبلغ", + "status": "الحالة", + "date": "التاريخ", + "noReports": "لا توجد بلاغات بعد", + "viewPrompt": "عرض الأمر", + "markReviewed": "تعيين كمراجع", + "dismiss": "رفض", + "markedReviewed": "تم تعيين البلاغ كمراجع", + "dismissed": "تم رفض البلاغ", + "updateFailed": "فشل في تحديث البلاغ", + "statuses": { + "pending": "قيد الانتظار", + "reviewed": "تمت المراجعة", + "dismissed": "مرفوض" + } }, "users": { "title": "إدارة المستخدمين", @@ -502,5 +524,27 @@ "browsePrompts": "تصفح الأوامر", "categories": "التصنيفات", "createPrompt": "إنشاء أمر" + }, + "report": { + "report": "إبلاغ", + "reportPrompt": "الإبلاغ عن الأمر", + "reportDescription": "ساعدنا في الحفاظ على أمان المجتمع من خلال الإبلاغ عن المحتوى غير المناسب.", + "reason": "السبب", + "selectReason": "اختر سبباً", + "reasons": { + "spam": "رسائل مزعجة أو إعلانات", + "inappropriate": "محتوى غير لائق", + "copyright": "انتهاك حقوق النشر", + "misleading": "معلومات مضللة أو كاذبة", + "other": "أخرى" + }, + "details": "تفاصيل إضافية", + "detailsPlaceholder": "يرجى تقديم مزيد من المعلومات حول هذا البلاغ...", + "optional": "اختياري", + "submitReport": "إرسال البلاغ", + "reportSubmitted": "تم إرسال البلاغ بنجاح", + "reportFailed": "فشل في إرسال البلاغ", + "reasonRequired": "يرجى اختيار سبب", + "alreadyReported": "لقد أبلغت عن هذا الأمر بالفعل" } } diff --git a/messages/de.json b/messages/de.json index 1138141b..d05916b5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -273,7 +273,29 @@ "categories": "Kategorien", "tags": "Tags", "webhooks": "Webhooks", - "prompts": "Prompts" + "prompts": "Prompts", + "reports": "Meldungen" + }, + "reports": { + "title": "Meldungsverwaltung", + "description": "Gemeldete Prompts überprüfen und verwalten", + "prompt": "Prompt", + "reason": "Grund", + "reportedBy": "Gemeldet von", + "status": "Status", + "date": "Datum", + "noReports": "Noch keine Meldungen", + "viewPrompt": "Prompt anzeigen", + "markReviewed": "Als überprüft markieren", + "dismiss": "Ablehnen", + "markedReviewed": "Meldung als überprüft markiert", + "dismissed": "Meldung abgelehnt", + "updateFailed": "Fehler beim Aktualisieren der Meldung", + "statuses": { + "pending": "Ausstehend", + "reviewed": "Überprüft", + "dismissed": "Abgelehnt" + } }, "prompts": { "title": "Prompt-Verwaltung", @@ -601,5 +623,27 @@ "browsePrompts": "Prompts durchsuchen", "categories": "Kategorien", "createPrompt": "Prompt erstellen" + }, + "report": { + "report": "Melden", + "reportPrompt": "Prompt melden", + "reportDescription": "Helfen Sie uns, die Community sicher zu halten, indem Sie unangemessene Inhalte melden.", + "reason": "Grund", + "selectReason": "Wählen Sie einen Grund", + "reasons": { + "spam": "Spam oder Werbung", + "inappropriate": "Unangemessener Inhalt", + "copyright": "Urheberrechtsverletzung", + "misleading": "Irreführende oder falsche Informationen", + "other": "Sonstiges" + }, + "details": "Zusätzliche Details", + "detailsPlaceholder": "Bitte geben Sie mehr Kontext zu dieser Meldung an...", + "optional": "optional", + "submitReport": "Meldung absenden", + "reportSubmitted": "Meldung erfolgreich gesendet", + "reportFailed": "Meldung konnte nicht gesendet werden", + "reasonRequired": "Bitte wählen Sie einen Grund", + "alreadyReported": "Sie haben diesen Prompt bereits gemeldet" } } diff --git a/messages/en.json b/messages/en.json index a7cd97e6..3634f954 100644 --- a/messages/en.json +++ b/messages/en.json @@ -273,7 +273,29 @@ "categories": "Categories", "tags": "Tags", "webhooks": "Webhooks", - "prompts": "Prompts" + "prompts": "Prompts", + "reports": "Reports" + }, + "reports": { + "title": "Reports Management", + "description": "Review and manage reported prompts", + "prompt": "Prompt", + "reason": "Reason", + "reportedBy": "Reported By", + "status": "Status", + "date": "Date", + "noReports": "No reports yet", + "viewPrompt": "View Prompt", + "markReviewed": "Mark as Reviewed", + "dismiss": "Dismiss", + "markedReviewed": "Report marked as reviewed", + "dismissed": "Report dismissed", + "updateFailed": "Failed to update report", + "statuses": { + "pending": "Pending", + "reviewed": "Reviewed", + "dismissed": "Dismissed" + } }, "prompts": { "title": "Prompts Management", @@ -601,5 +623,27 @@ "browsePrompts": "Browse Prompts", "categories": "Categories", "createPrompt": "Create Prompt" + }, + "report": { + "report": "Report", + "reportPrompt": "Report Prompt", + "reportDescription": "Help us keep the community safe by reporting inappropriate content.", + "reason": "Reason", + "selectReason": "Select a reason", + "reasons": { + "spam": "Spam or advertising", + "inappropriate": "Inappropriate content", + "copyright": "Copyright violation", + "misleading": "Misleading or false information", + "other": "Other" + }, + "details": "Additional details", + "detailsPlaceholder": "Please provide more context about this report...", + "optional": "optional", + "submitReport": "Submit Report", + "reportSubmitted": "Report submitted successfully", + "reportFailed": "Failed to submit report", + "reasonRequired": "Please select a reason", + "alreadyReported": "You have already reported this prompt" } } diff --git a/messages/es.json b/messages/es.json index f2e5f773..25fb2960 100644 --- a/messages/es.json +++ b/messages/es.json @@ -253,7 +253,29 @@ "categories": "Categorías", "tags": "Etiquetas", "webhooks": "Webhooks", - "import": "Importar Prompts de la Comunidad" + "prompts": "Prompts", + "reports": "Reportes" + }, + "reports": { + "title": "Gestión de Reportes", + "description": "Revisa y gestiona los prompts reportados", + "prompt": "Prompt", + "reason": "Motivo", + "reportedBy": "Reportado por", + "status": "Estado", + "date": "Fecha", + "noReports": "No hay reportes aún", + "viewPrompt": "Ver Prompt", + "markReviewed": "Marcar como Revisado", + "dismiss": "Descartar", + "markedReviewed": "Reporte marcado como revisado", + "dismissed": "Reporte descartado", + "updateFailed": "Error al actualizar el reporte", + "statuses": { + "pending": "Pendiente", + "reviewed": "Revisado", + "dismissed": "Descartado" + } }, "users": { "title": "Gestión de Usuarios", @@ -519,5 +541,27 @@ "browsePrompts": "Explorar Prompts", "categories": "Categorías", "createPrompt": "Crear Prompt" + }, + "report": { + "report": "Reportar", + "reportPrompt": "Reportar Prompt", + "reportDescription": "Ayúdanos a mantener la comunidad segura reportando contenido inapropiado.", + "reason": "Motivo", + "selectReason": "Selecciona un motivo", + "reasons": { + "spam": "Spam o publicidad", + "inappropriate": "Contenido inapropiado", + "copyright": "Violación de derechos de autor", + "misleading": "Información engañosa o falsa", + "other": "Otro" + }, + "details": "Detalles adicionales", + "detailsPlaceholder": "Proporciona más contexto sobre este reporte...", + "optional": "opcional", + "submitReport": "Enviar Reporte", + "reportSubmitted": "Reporte enviado exitosamente", + "reportFailed": "Error al enviar el reporte", + "reasonRequired": "Por favor selecciona un motivo", + "alreadyReported": "Ya has reportado este prompt" } } diff --git a/messages/fr.json b/messages/fr.json index 5d8cd0d1..bde29339 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -273,7 +273,29 @@ "categories": "Catégories", "tags": "Tags", "webhooks": "Webhooks", - "prompts": "Prompts" + "prompts": "Prompts", + "reports": "Signalements" + }, + "reports": { + "title": "Gestion des Signalements", + "description": "Examiner et gérer les prompts signalés", + "prompt": "Prompt", + "reason": "Raison", + "reportedBy": "Signalé par", + "status": "Statut", + "date": "Date", + "noReports": "Aucun signalement pour l'instant", + "viewPrompt": "Voir le Prompt", + "markReviewed": "Marquer comme Examiné", + "dismiss": "Rejeter", + "markedReviewed": "Signalement marqué comme examiné", + "dismissed": "Signalement rejeté", + "updateFailed": "Échec de la mise à jour du signalement", + "statuses": { + "pending": "En attente", + "reviewed": "Examiné", + "dismissed": "Rejeté" + } }, "prompts": { "title": "Gestion des Prompts", @@ -601,5 +623,27 @@ "browsePrompts": "Parcourir les Prompts", "categories": "Catégories", "createPrompt": "Créer un Prompt" + }, + "report": { + "report": "Signaler", + "reportPrompt": "Signaler le Prompt", + "reportDescription": "Aidez-nous à maintenir la communauté en sécurité en signalant les contenus inappropriés.", + "reason": "Raison", + "selectReason": "Sélectionnez une raison", + "reasons": { + "spam": "Spam ou publicité", + "inappropriate": "Contenu inapproprié", + "copyright": "Violation des droits d'auteur", + "misleading": "Information trompeuse ou fausse", + "other": "Autre" + }, + "details": "Détails supplémentaires", + "detailsPlaceholder": "Veuillez fournir plus de contexte sur ce signalement...", + "optional": "optionnel", + "submitReport": "Envoyer le Signalement", + "reportSubmitted": "Signalement envoyé avec succès", + "reportFailed": "Échec de l'envoi du signalement", + "reasonRequired": "Veuillez sélectionner une raison", + "alreadyReported": "Vous avez déjà signalé ce prompt" } } diff --git a/messages/it.json b/messages/it.json index de878bac..573b7ae2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -273,7 +273,29 @@ "categories": "Categorie", "tags": "Tag", "webhooks": "Webhook", - "prompts": "Prompt" + "prompts": "Prompt", + "reports": "Segnalazioni" + }, + "reports": { + "title": "Gestione Segnalazioni", + "description": "Revisiona e gestisci i prompt segnalati", + "prompt": "Prompt", + "reason": "Motivo", + "reportedBy": "Segnalato da", + "status": "Stato", + "date": "Data", + "noReports": "Nessuna segnalazione", + "viewPrompt": "Visualizza Prompt", + "markReviewed": "Segna come Revisionato", + "dismiss": "Archivia", + "markedReviewed": "Segnalazione revisionata", + "dismissed": "Segnalazione archiviata", + "updateFailed": "Impossibile aggiornare la segnalazione", + "statuses": { + "pending": "In attesa", + "reviewed": "Revisionato", + "dismissed": "Archiviato" + } }, "prompts": { "title": "Gestione Prompt", @@ -601,5 +623,27 @@ "browsePrompts": "Sfoglia Prompt", "categories": "Categorie", "createPrompt": "Crea Prompt" + }, + "report": { + "report": "Segnala", + "reportPrompt": "Segnala Prompt", + "reportDescription": "Aiutaci a mantenere la community sicura segnalando contenuti inappropriati.", + "reason": "Motivo", + "selectReason": "Seleziona un motivo", + "reasons": { + "spam": "Spam o pubblicità", + "inappropriate": "Contenuto inappropriato", + "copyright": "Violazione del copyright", + "misleading": "Informazioni fuorvianti o false", + "other": "Altro" + }, + "details": "Dettagli aggiuntivi", + "detailsPlaceholder": "Fornisci maggiori dettagli su questa segnalazione...", + "optional": "opzionale", + "submitReport": "Invia Segnalazione", + "reportSubmitted": "Segnalazione inviata con successo", + "reportFailed": "Impossibile inviare la segnalazione", + "reasonRequired": "Seleziona un motivo", + "alreadyReported": "Hai già segnalato questo prompt" } } diff --git a/messages/ja.json b/messages/ja.json index df822103..575f3526 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -253,7 +253,29 @@ "categories": "カテゴリー", "tags": "タグ", "webhooks": "Webhook", - "import": "コミュニティプロンプトをインポート" + "prompts": "プロンプト", + "reports": "報告" + }, + "reports": { + "title": "報告管理", + "description": "報告されたプロンプトを確認・管理", + "prompt": "プロンプト", + "reason": "理由", + "reportedBy": "報告者", + "status": "ステータス", + "date": "日付", + "noReports": "報告はまだありません", + "viewPrompt": "プロンプトを見る", + "markReviewed": "確認済みにする", + "dismiss": "却下", + "markedReviewed": "報告を確認済みにしました", + "dismissed": "報告を却下しました", + "updateFailed": "報告の更新に失敗しました", + "statuses": { + "pending": "保留中", + "reviewed": "確認済み", + "dismissed": "却下済み" + } }, "users": { "title": "ユーザー管理", @@ -496,5 +518,27 @@ "browsePrompts": "プロンプトを見る", "categories": "カテゴリー", "createPrompt": "プロンプトを作成" + }, + "report": { + "report": "報告", + "reportPrompt": "プロンプトを報告", + "reportDescription": "不適切なコンテンツを報告して、コミュニティの安全を守るためにご協力ください。", + "reason": "理由", + "selectReason": "理由を選択", + "reasons": { + "spam": "スパムまたは広告", + "inappropriate": "不適切なコンテンツ", + "copyright": "著作権侵害", + "misleading": "誤解を招く情報または虚偽情報", + "other": "その他" + }, + "details": "詳細情報", + "detailsPlaceholder": "この報告についての詳細を入力してください...", + "optional": "任意", + "submitReport": "報告を送信", + "reportSubmitted": "報告が送信されました", + "reportFailed": "報告の送信に失敗しました", + "reasonRequired": "理由を選択してください", + "alreadyReported": "このプロンプトは既に報告済みです" } } diff --git a/messages/ko.json b/messages/ko.json index 7a2060b5..11ae8c76 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -273,7 +273,29 @@ "categories": "카테고리", "tags": "태그", "webhooks": "웹훅", - "prompts": "프롬프트" + "prompts": "프롬프트", + "reports": "신고" + }, + "reports": { + "title": "신고 관리", + "description": "신고된 프롬프트를 검토하고 관리합니다", + "prompt": "프롬프트", + "reason": "사유", + "reportedBy": "신고자", + "status": "상태", + "date": "날짜", + "noReports": "아직 신고가 없습니다", + "viewPrompt": "프롬프트 보기", + "markReviewed": "검토 완료로 표시", + "dismiss": "기각", + "markedReviewed": "신고가 검토 완료로 표시되었습니다", + "dismissed": "신고가 기각되었습니다", + "updateFailed": "신고 업데이트에 실패했습니다", + "statuses": { + "pending": "대기 중", + "reviewed": "검토 완료", + "dismissed": "기각됨" + } }, "prompts": { "title": "프롬프트 관리", @@ -601,5 +623,27 @@ "browsePrompts": "프롬프트 둘러보기", "categories": "카테고리", "createPrompt": "프롬프트 만들기" + }, + "report": { + "report": "신고", + "reportPrompt": "프롬프트 신고", + "reportDescription": "부적절한 콘텐츠를 신고하여 커뮤니티를 안전하게 유지하는 데 도움을 주세요.", + "reason": "사유", + "selectReason": "사유를 선택하세요", + "reasons": { + "spam": "스팸 또는 광고", + "inappropriate": "부적절한 콘텐츠", + "copyright": "저작권 침해", + "misleading": "오해의 소지가 있거나 허위 정보", + "other": "기타" + }, + "details": "추가 세부사항", + "detailsPlaceholder": "이 신고에 대한 자세한 내용을 입력하세요...", + "optional": "선택사항", + "submitReport": "신고 제출", + "reportSubmitted": "신고가 성공적으로 제출되었습니다", + "reportFailed": "신고 제출에 실패했습니다", + "reasonRequired": "사유를 선택하세요", + "alreadyReported": "이미 이 프롬프트를 신고하셨습니다" } } diff --git a/messages/pt.json b/messages/pt.json index df78eb94..6323c282 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -273,7 +273,29 @@ "categories": "Categorias", "tags": "Tags", "webhooks": "Webhooks", - "prompts": "Prompts" + "prompts": "Prompts", + "reports": "Denúncias" + }, + "reports": { + "title": "Gestão de Denúncias", + "description": "Revise e gerencie prompts denunciados", + "prompt": "Prompt", + "reason": "Motivo", + "reportedBy": "Denunciado por", + "status": "Estado", + "date": "Data", + "noReports": "Nenhuma denúncia ainda", + "viewPrompt": "Ver Prompt", + "markReviewed": "Marcar como Revisado", + "dismiss": "Descartar", + "markedReviewed": "Denúncia marcada como revisada", + "dismissed": "Denúncia descartada", + "updateFailed": "Falha ao atualizar denúncia", + "statuses": { + "pending": "Pendente", + "reviewed": "Revisado", + "dismissed": "Descartado" + } }, "prompts": { "title": "Gerenciamento de Prompts", @@ -601,5 +623,27 @@ "browsePrompts": "Ver Prompts", "categories": "Categorias", "createPrompt": "Criar Prompt" + }, + "report": { + "report": "Denunciar", + "reportPrompt": "Denunciar Prompt", + "reportDescription": "Ajude-nos a manter a comunidade segura denunciando conteúdo inadequado.", + "reason": "Motivo", + "selectReason": "Selecione um motivo", + "reasons": { + "spam": "Spam ou publicidade", + "inappropriate": "Conteúdo inadequado", + "copyright": "Violação de direitos autorais", + "misleading": "Informação enganosa ou falsa", + "other": "Outro" + }, + "details": "Detalhes adicionais", + "detailsPlaceholder": "Forneça mais contexto sobre esta denúncia...", + "optional": "opcional", + "submitReport": "Enviar Denúncia", + "reportSubmitted": "Denúncia enviada com sucesso", + "reportFailed": "Falha ao enviar denúncia", + "reasonRequired": "Por favor, selecione um motivo", + "alreadyReported": "Você já denunciou este prompt" } } diff --git a/messages/tr.json b/messages/tr.json index 1356caef..918629d8 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -273,7 +273,29 @@ "categories": "Kategoriler", "tags": "Etiketler", "webhooks": "Webhooklar", - "prompts": "Promptlar" + "prompts": "Promptlar", + "reports": "Şikayetler" + }, + "reports": { + "title": "Şikayet Yönetimi", + "description": "Şikayet edilen promptları incele ve yönet", + "prompt": "Prompt", + "reason": "Sebep", + "reportedBy": "Şikayet Eden", + "status": "Durum", + "date": "Tarih", + "noReports": "Henüz şikayet yok", + "viewPrompt": "Promptu Görüntüle", + "markReviewed": "İncelendi Olarak İşaretle", + "dismiss": "Reddet", + "markedReviewed": "Şikayet incelendi olarak işaretlendi", + "dismissed": "Şikayet reddedildi", + "updateFailed": "Şikayet güncellenemedi", + "statuses": { + "pending": "Beklemede", + "reviewed": "İncelendi", + "dismissed": "Reddedildi" + } }, "prompts": { "title": "Prompt Yönetimi", @@ -601,5 +623,27 @@ "browsePrompts": "Promptlara Göz At", "categories": "Kategoriler", "createPrompt": "Prompt Oluştur" + }, + "report": { + "report": "Şikayet Et", + "reportPrompt": "Promptu Şikayet Et", + "reportDescription": "Uygunsuz içerikleri bildirerek topluluğumuzu güvende tutmamıza yardımcı olun.", + "reason": "Sebep", + "selectReason": "Bir sebep seçin", + "reasons": { + "spam": "Spam veya reklam", + "inappropriate": "Uygunsuz içerik", + "copyright": "Telif hakkı ihlali", + "misleading": "Yanıltıcı veya yanlış bilgi", + "other": "Diğer" + }, + "details": "Ek detaylar", + "detailsPlaceholder": "Bu şikayet hakkında daha fazla bilgi verin...", + "optional": "isteğe bağlı", + "submitReport": "Şikayeti Gönder", + "reportSubmitted": "Şikayet başarıyla gönderildi", + "reportFailed": "Şikayet gönderilemedi", + "reasonRequired": "Lütfen bir sebep seçin", + "alreadyReported": "Bu promptu zaten şikayet ettiniz" } } diff --git a/messages/zh.json b/messages/zh.json index 3cf40593..b6907162 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -253,7 +253,29 @@ "categories": "分类", "tags": "标签", "webhooks": "Webhooks", - "import": "导入社区提示词" + "prompts": "提示词", + "reports": "举报" + }, + "reports": { + "title": "举报管理", + "description": "审核和管理被举报的提示词", + "prompt": "提示词", + "reason": "原因", + "reportedBy": "举报人", + "status": "状态", + "date": "日期", + "noReports": "暂无举报", + "viewPrompt": "查看提示词", + "markReviewed": "标记为已审核", + "dismiss": "驳回", + "markedReviewed": "举报已标记为已审核", + "dismissed": "举报已驳回", + "updateFailed": "更新举报失败", + "statuses": { + "pending": "待处理", + "reviewed": "已审核", + "dismissed": "已驳回" + } }, "users": { "title": "用户管理", @@ -496,5 +518,27 @@ "browsePrompts": "浏览提示词", "categories": "分类", "createPrompt": "创建提示词" + }, + "report": { + "report": "举报", + "reportPrompt": "举报提示词", + "reportDescription": "通过举报不当内容,帮助我们维护社区安全。", + "reason": "原因", + "selectReason": "选择原因", + "reasons": { + "spam": "垃圾信息或广告", + "inappropriate": "不当内容", + "copyright": "侵犯版权", + "misleading": "误导性或虚假信息", + "other": "其他" + }, + "details": "详细说明", + "detailsPlaceholder": "请提供更多关于此举报的信息...", + "optional": "可选", + "submitReport": "提交举报", + "reportSubmitted": "举报提交成功", + "reportFailed": "举报提交失败", + "reasonRequired": "请选择举报原因", + "alreadyReported": "您已经举报过此提示词" } } diff --git a/prisma/migrations/20251213100000_add_verified_and_reports/migration.sql b/prisma/migrations/20251213100000_add_verified_and_reports/migration.sql new file mode 100644 index 00000000..6711589e --- /dev/null +++ b/prisma/migrations/20251213100000_add_verified_and_reports/migration.sql @@ -0,0 +1,45 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "verified" BOOLEAN NOT NULL DEFAULT false; + +-- CreateEnum (if not exists) +DO $$ BEGIN + CREATE TYPE "ReportReason" AS ENUM ('SPAM', 'INAPPROPRIATE', 'COPYRIGHT', 'MISLEADING', 'OTHER'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateEnum (if not exists) +DO $$ BEGIN + CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'REVIEWED', 'DISMISSED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- CreateTable (if not exists) +CREATE TABLE IF NOT EXISTS "prompt_reports" ( + "id" TEXT NOT NULL, + "reason" "ReportReason" NOT NULL, + "details" TEXT, + "status" "ReportStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "promptId" TEXT NOT NULL, + "reporterId" TEXT NOT NULL, + + CONSTRAINT "prompt_reports_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "prompt_reports_promptId_idx" ON "prompt_reports"("promptId"); + +-- CreateIndex +CREATE INDEX "prompt_reports_reporterId_idx" ON "prompt_reports"("reporterId"); + +-- CreateIndex +CREATE INDEX "prompt_reports_status_idx" ON "prompt_reports"("status"); + +-- AddForeignKey +ALTER TABLE "prompt_reports" ADD CONSTRAINT "prompt_reports_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "prompt_reports" ADD CONSTRAINT "prompt_reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11df2ecb..f741ce08 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { subscriptions CategorySubscription[] votes PromptVote[] pinnedPrompts PinnedPrompt[] + reports PromptReport[] @@map("users") } @@ -153,6 +154,7 @@ model Prompt { votes PromptVote[] contributors User[] @relation("PromptContributors") pinnedBy PinnedPrompt[] + reports PromptReport[] @@index([authorId]) @@index([categoryId]) @@ -295,6 +297,44 @@ model PinnedPrompt { @@map("pinned_prompts") } +// ============================================ +// Prompt Reports +// ============================================ + +enum ReportReason { + SPAM + INAPPROPRIATE + COPYRIGHT + MISLEADING + OTHER +} + +enum ReportStatus { + PENDING + REVIEWED + DISMISSED +} + +model PromptReport { + id String @id @default(cuid()) + reason ReportReason + details String? @db.Text + status ReportStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + promptId String + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + reporterId String + reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) + + @@index([promptId]) + @@index([reporterId]) + @@index([status]) + @@map("prompt_reports") +} + // ============================================ // Webhook Integration // ============================================ diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index c2b8aa99..ab9ecab2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -6,12 +6,13 @@ import { db } from "@/lib/db"; import { Prisma } from "@prisma/client"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Users, FolderTree, Tags, FileText, Webhook } from "lucide-react"; +import { Users, FolderTree, Tags, FileText, Webhook, Flag } from "lucide-react"; import { UsersTable } from "@/components/admin/users-table"; import { CategoriesTable } from "@/components/admin/categories-table"; import { TagsTable } from "@/components/admin/tags-table"; import { WebhooksTable } from "@/components/admin/webhooks-table"; import { PromptsManagement } from "@/components/admin/prompts-management"; +import { ReportsTable } from "@/components/admin/reports-table"; import { isAISearchEnabled } from "@/lib/ai/embeddings"; export const metadata: Metadata = { @@ -49,7 +50,7 @@ export default async function AdminPage() { } // Fetch data for tables - const [users, categories, tags, webhooks] = await Promise.all([ + const [users, categories, tags, webhooks, reports] = await Promise.all([ db.user.findMany({ orderBy: { createdAt: "desc" }, select: { @@ -98,6 +99,25 @@ export default async function AdminPage() { db.webhookConfig.findMany({ orderBy: { createdAt: "desc" }, }), + db.promptReport.findMany({ + orderBy: { createdAt: "desc" }, + include: { + prompt: { + select: { + id: true, + title: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + name: true, + avatar: true, + }, + }, + }, + }), ]); return ( @@ -170,6 +190,15 @@ export default async function AdminPage() { {t("tabs.prompts")} + + + {t("tabs.reports")} + {reports.filter(r => r.status === "PENDING").length > 0 && ( + + {reports.filter(r => r.status === "PENDING").length} + + )} + @@ -194,6 +223,10 @@ export default async function AdminPage() { promptsWithoutEmbeddings={promptsWithoutEmbeddings} /> + + + + ); diff --git a/src/app/api/admin/reports/[id]/route.ts b/src/app/api/admin/reports/[id]/route.ts new file mode 100644 index 00000000..48fd8a92 --- /dev/null +++ b/src/app/api/admin/reports/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; + +const updateSchema = z.object({ + status: z.enum(["PENDING", "REVIEWED", "DISMISSED"]), +}); + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user || session.user.role !== "ADMIN") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const { status } = updateSchema.parse(body); + + const report = await db.promptReport.update({ + where: { id }, + data: { status }, + }); + + return NextResponse.json(report); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: "Invalid data" }, { status: 400 }); + } + console.error("Report update error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts new file mode 100644 index 00000000..83150865 --- /dev/null +++ b/src/app/api/reports/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; + +const reportSchema = z.object({ + promptId: z.string().min(1), + reason: z.enum(["SPAM", "INAPPROPRIATE", "COPYRIGHT", "MISLEADING", "OTHER"]), + details: z.string().optional(), +}); + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { promptId, reason, details } = reportSchema.parse(body); + + // Check if prompt exists + const prompt = await db.prompt.findUnique({ + where: { id: promptId }, + select: { id: true, authorId: true }, + }); + + if (!prompt) { + return NextResponse.json({ error: "Prompt not found" }, { status: 404 }); + } + + // Prevent self-reporting + if (prompt.authorId === session.user.id) { + return NextResponse.json( + { error: "You cannot report your own prompt" }, + { status: 400 } + ); + } + + // Check if user already reported this prompt + const existingReport = await db.promptReport.findFirst({ + where: { + promptId, + reporterId: session.user.id, + status: "PENDING", + }, + }); + + if (existingReport) { + return NextResponse.json( + { error: "You have already reported this prompt" }, + { status: 400 } + ); + } + + // Create the report + await db.promptReport.create({ + data: { + promptId, + reporterId: session.user.id, + reason, + details: details || null, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data" }, + { status: 400 } + ); + } + console.error("Report creation error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/prompts/[id]/page.tsx b/src/app/prompts/[id]/page.tsx index 2d0b731c..e3878ec7 100644 --- a/src/app/prompts/[id]/page.tsx +++ b/src/app/prompts/[id]/page.tsx @@ -20,6 +20,7 @@ import { VersionCompareModal } from "@/components/prompts/version-compare-modal" import { VersionCompareButton } from "@/components/prompts/version-compare-button"; import { FeaturePromptButton } from "@/components/prompts/feature-prompt-button"; import { MediaPreview } from "@/components/prompts/media-preview"; +import { ReportPromptDialog } from "@/components/prompts/report-prompt-dialog"; interface PromptPageProps { params: Promise<{ id: string }>; @@ -380,6 +381,12 @@ export default async function PromptPage({ params }: PromptPageProps) { )} + {/* Report link */} + {!isOwner && ( +
+ +
+ )} diff --git a/src/components/admin/reports-table.tsx b/src/components/admin/reports-table.tsx new file mode 100644 index 00000000..554fb36a --- /dev/null +++ b/src/components/admin/reports-table.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations, useLocale } from "next-intl"; +import Link from "next/link"; +import { formatDistanceToNow } from "@/lib/date"; +import { MoreHorizontal, Check, X, Eye, ExternalLink } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "sonner"; + +interface Report { + id: string; + reason: "SPAM" | "INAPPROPRIATE" | "COPYRIGHT" | "MISLEADING" | "OTHER"; + details: string | null; + status: "PENDING" | "REVIEWED" | "DISMISSED"; + createdAt: Date; + prompt: { + id: string; + title: string; + }; + reporter: { + id: string; + username: string; + name: string | null; + avatar: string | null; + }; +} + +interface ReportsTableProps { + reports: Report[]; +} + +export function ReportsTable({ reports }: ReportsTableProps) { + const router = useRouter(); + const t = useTranslations("admin.reports"); + const tReport = useTranslations("report"); + const locale = useLocale(); + const [loading, setLoading] = useState(null); + + const handleStatusChange = async (reportId: string, status: "REVIEWED" | "DISMISSED") => { + setLoading(reportId); + try { + const res = await fetch(`/api/admin/reports/${reportId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + + if (!res.ok) throw new Error("Failed to update status"); + + toast.success(status === "REVIEWED" ? t("markedReviewed") : t("dismissed")); + router.refresh(); + } catch { + toast.error(t("updateFailed")); + } finally { + setLoading(null); + } + }; + + const statusColors = { + PENDING: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20", + REVIEWED: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20", + DISMISSED: "bg-muted text-muted-foreground", + }; + + const reasonLabels: Record = { + SPAM: tReport("reasons.spam"), + INAPPROPRIATE: tReport("reasons.inappropriate"), + COPYRIGHT: tReport("reasons.copyright"), + MISLEADING: tReport("reasons.misleading"), + OTHER: tReport("reasons.other"), + }; + + return ( + <> +
+
+

{t("title")}

+

{t("description")}

+
+
+ + {reports.length === 0 ? ( +
+ {t("noReports")} +
+ ) : ( +
+ + + + {t("prompt")} + {t("reason")} + {t("reportedBy")} + {t("status")} + {t("date")} + + + + + {reports.map((report) => ( + + + + {report.prompt.title} + + + + +
+ {reasonLabels[report.reason]} + {report.details && ( +

+ {report.details} +

+ )} +
+
+ +
+ + + + {report.reporter.name?.charAt(0) || report.reporter.username.charAt(0)} + + + @{report.reporter.username} +
+
+ + + {t(`statuses.${report.status.toLowerCase()}`)} + + + + {formatDistanceToNow(report.createdAt, locale)} + + + + + + + + + + + {t("viewPrompt")} + + + + {report.status === "PENDING" && ( + <> + handleStatusChange(report.id, "REVIEWED")}> + + {t("markReviewed")} + + handleStatusChange(report.id, "DISMISSED")}> + + {t("dismiss")} + + + )} + {report.status !== "PENDING" && ( + handleStatusChange(report.id, "REVIEWED")} + disabled={report.status === "REVIEWED"} + > + + {t("markReviewed")} + + )} + + + +
+ ))} +
+
+
+ )} + + ); +} diff --git a/src/components/prompts/report-prompt-dialog.tsx b/src/components/prompts/report-prompt-dialog.tsx new file mode 100644 index 00000000..303d8038 --- /dev/null +++ b/src/components/prompts/report-prompt-dialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Flag, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface ReportPromptDialogProps { + promptId: string; + isLoggedIn: boolean; +} + +const REPORT_REASONS = ["SPAM", "INAPPROPRIATE", "COPYRIGHT", "MISLEADING", "OTHER"] as const; + +export function ReportPromptDialog({ promptId, isLoggedIn }: ReportPromptDialogProps) { + const t = useTranslations("report"); + const tCommon = useTranslations("common"); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(""); + const [details, setDetails] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + if (!reason) { + toast.error(t("reasonRequired")); + return; + } + + setIsSubmitting(true); + try { + const response = await fetch("/api/reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ promptId, reason, details }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to submit report"); + } + + toast.success(t("reportSubmitted")); + setOpen(false); + setReason(""); + setDetails(""); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("reportFailed")); + } finally { + setIsSubmitting(false); + } + }; + + if (!isLoggedIn) { + return null; + } + + return ( + + + + + + + {t("reportPrompt")} + {t("reportDescription")} + +
+
+ + +
+
+ +