mirror of
https://github.com/f/awesome-chatgpt-prompts.git
synced 2026-02-12 15:52:47 +00:00
Initialize v2 work
This commit is contained in:
24
.cursorrules
24
.cursorrules
@@ -1,24 +0,0 @@
|
||||
# Project Configuration
|
||||
|
||||
## Project Type
|
||||
- Static Site Generator: Jekyll
|
||||
- Hosting: GitHub Pages
|
||||
|
||||
|
||||
|
||||
## Build Commands
|
||||
- `bundle install`: Install dependencies
|
||||
- `bundle exec jekyll serve`: Run development server
|
||||
- `bundle exec jekyll build`: Build site for production
|
||||
|
||||
## Important Files
|
||||
- `_config.yml`: Jekyll configuration
|
||||
- `Gemfile`: Ruby dependencies
|
||||
- `_site/`: Build output directory (generated)
|
||||
- `_posts/`: Blog posts directory
|
||||
- `_layouts/`: Template layouts
|
||||
- `_includes/`: Reusable components
|
||||
|
||||
## GitHub Pages Settings
|
||||
- Branch: gh-pages (or main/master, depending on your setup)
|
||||
- Build Source: Jekyll
|
||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/prompts_chat?schema=public"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
|
||||
|
||||
# OAuth Providers (optional - enable in prompts.config.ts)
|
||||
# GOOGLE_CLIENT_ID=""
|
||||
# GOOGLE_CLIENT_SECRET=""
|
||||
# AZURE_AD_CLIENT_ID=""
|
||||
# AZURE_AD_CLIENT_SECRET=""
|
||||
# AZURE_AD_TENANT_ID=""
|
||||
|
||||
# Storage Providers (optional - enable in prompts.config.ts)
|
||||
# S3_BUCKET=""
|
||||
# S3_REGION=""
|
||||
# S3_ACCESS_KEY_ID=""
|
||||
# S3_SECRET_ACCESS_KEY=""
|
||||
# S3_ENDPOINT="" # For S3-compatible services like MinIO
|
||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,3 +1 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [f]
|
||||
|
||||
20
.github/pull_request_template.md
vendored
20
.github/pull_request_template.md
vendored
@@ -1,20 +0,0 @@
|
||||
# Add New Prompt
|
||||
|
||||
You'll need to add your prompt into README.md, and to the `prompts.csv` file. If
|
||||
your prompt includes quotes, you will need to double-quote them to escape in CSV
|
||||
file.
|
||||
|
||||
If the prompt is generated by AI, please add `<mark>Generated by AI</mark>` to
|
||||
the end of the contribution line.
|
||||
|
||||
- [ ] I've confirmed the prompt works well
|
||||
- [ ] I've added
|
||||
`Contributed by: [@yourusername](https://github.com/yourusername)`
|
||||
- [ ] I've added to the README.md
|
||||
- [ ] I've added to the `prompts.csv`
|
||||
- [ ] Escaped quotes by double-quoting them
|
||||
- [ ] No spaces after commas after double quotes. e.g. `"Hello","hi"`, not
|
||||
`"Hello", "hi"`
|
||||
- [ ] Removed "Act as" from the title on CSV
|
||||
|
||||
Please make sure you've completed all the checklist.
|
||||
623
.github/workflows/ai_bot.yml
vendored
623
.github/workflows/ai_bot.yml
vendored
@@ -1,623 +0,0 @@
|
||||
name: AI Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
respond-to-commands:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.actor == 'f') &&
|
||||
((github.event_name == 'issues' && contains(github.event.issue.body, '/ai')) ||
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai')) ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.body, '/ai')))
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install openai@^4.0.0 @octokit/rest@^19.0.0
|
||||
|
||||
- name: Process command
|
||||
id: process
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PAT_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
node << 'EOF'
|
||||
const OpenAI = require('openai');
|
||||
const { Octokit } = require('@octokit/rest');
|
||||
|
||||
async function main() {
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
});
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: process.env.GH_TOKEN
|
||||
});
|
||||
|
||||
const eventName = process.env.GITHUB_EVENT_NAME;
|
||||
const eventPath = process.env.GITHUB_EVENT_PATH;
|
||||
const event = require(eventPath);
|
||||
|
||||
// Double check user authorization
|
||||
const actor = event.sender?.login || event.pull_request?.user?.login || event.issue?.user?.login;
|
||||
if (actor !== 'f') {
|
||||
console.log('Unauthorized user attempted to use the bot:', actor);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get command and context
|
||||
let command = '';
|
||||
let issueNumber = null;
|
||||
let isPullRequest = false;
|
||||
|
||||
if (eventName === 'issues') {
|
||||
command = event.issue.body;
|
||||
issueNumber = event.issue.number;
|
||||
} else if (eventName === 'issue_comment') {
|
||||
command = event.comment.body;
|
||||
issueNumber = event.issue.number;
|
||||
isPullRequest = !!event.issue.pull_request;
|
||||
} else if (eventName === 'pull_request_review_comment') {
|
||||
command = event.comment.body;
|
||||
issueNumber = event.pull_request.number;
|
||||
isPullRequest = true;
|
||||
} else if (eventName === 'pull_request') {
|
||||
command = event.pull_request.body;
|
||||
issueNumber = event.pull_request.number;
|
||||
isPullRequest = true;
|
||||
}
|
||||
|
||||
if (!command.startsWith('/ai')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the actual command after /ai
|
||||
const aiCommand = command.substring(3).trim();
|
||||
|
||||
// Handle resolve conflicts command
|
||||
if (aiCommand === 'resolve' || aiCommand === 'fix conflicts') {
|
||||
if (!isPullRequest) {
|
||||
console.log('Command rejected: Not a pull request');
|
||||
await octokit.issues.createComment({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
issue_number: issueNumber,
|
||||
body: '❌ The resolve command can only be used on pull requests.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting resolve command execution...');
|
||||
|
||||
// Get PR details
|
||||
console.log('Fetching PR details...');
|
||||
const { data: pr } = await octokit.pulls.get({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
pull_number: issueNumber
|
||||
});
|
||||
console.log(`Original PR found: #${issueNumber} from ${pr.user.login}`);
|
||||
|
||||
// Get the PR diff to extract the new prompt
|
||||
console.log('Fetching PR file changes...');
|
||||
const { data: files } = await octokit.pulls.listFiles({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
pull_number: issueNumber
|
||||
});
|
||||
console.log(`Found ${files.length} changed files`);
|
||||
|
||||
// Extract prompt from changes
|
||||
console.log('Analyzing changes to extract prompt information...');
|
||||
const prompts = new Map(); // Use Map to deduplicate prompts by title
|
||||
|
||||
// Helper function to normalize prompt titles
|
||||
const normalizeTitle = (title) => {
|
||||
title = title.trim();
|
||||
// Remove "Act as" or "Act as a" or "Act as an" from start if present
|
||||
title = title.replace(/^Act as (?:a |an )?/i, '');
|
||||
|
||||
// Capitalize each word except common articles and prepositions
|
||||
const lowercaseWords = ['a', 'an', 'the', 'and', 'but', 'or', 'for', 'nor', 'on', 'at', 'to', 'for', 'with', 'in'];
|
||||
const capitalized = title.toLowerCase().split(' ').map((word, index) => {
|
||||
// Always capitalize first and last word
|
||||
if (index === 0 || !lowercaseWords.includes(word)) {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
return word;
|
||||
}).join(' ');
|
||||
|
||||
// Add "Act as" prefix
|
||||
return `Act as ${capitalized}`;
|
||||
};
|
||||
|
||||
// First try to find prompts in README
|
||||
let foundInReadme = false;
|
||||
for (const file of files) {
|
||||
console.log(`Processing file: ${file.filename}`);
|
||||
if (file.filename === 'README.md') {
|
||||
const patch = file.patch || '';
|
||||
const addedLines = patch.split('\n')
|
||||
.filter(line => line.startsWith('+'))
|
||||
.map(line => line.substring(1))
|
||||
.join('\n');
|
||||
|
||||
console.log('Attempting to extract prompts from README changes...');
|
||||
const promptMatches = [...addedLines.matchAll(/## (?:Act as (?:a |an )?)?([^\n]+)\n(?:Contributed by:[^\n]*\n)?(?:> )?([^#]+?)(?=\n##|\n\n##|$)/ig)];
|
||||
|
||||
for (const match of promptMatches) {
|
||||
const actName = normalizeTitle(match[1]);
|
||||
const promptText = match[2].trim()
|
||||
.replace(/^(?:Contributed by:?[^\n]*\n\s*)+/i, '')
|
||||
.trim();
|
||||
const contributorLine = addedLines.match(/Contributed by: \[@([^\]]+)\]\(https:\/\/github\.com\/([^\)]+)\)/);
|
||||
const contributorInfo = contributorLine
|
||||
? `Contributed by: [@${contributorLine[1]}](https://github.com/${contributorLine[2]})`
|
||||
: `Contributed by: [@${pr.user.login}](https://github.com/${pr.user.login})`;
|
||||
|
||||
prompts.set(actName.toLowerCase(), { actName, promptText, contributorInfo });
|
||||
console.log(`Found prompt in README: "${actName}"`);
|
||||
foundInReadme = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only look in CSV if we didn't find anything in README
|
||||
if (!foundInReadme) {
|
||||
console.log('No prompts found in README, checking CSV...');
|
||||
for (const file of files) {
|
||||
if (file.filename === 'prompts.csv') {
|
||||
const patch = file.patch || '';
|
||||
const addedLines = patch.split('\n')
|
||||
.filter(line => line.startsWith('+'))
|
||||
.map(line => line.substring(1))
|
||||
.filter(line => line.trim()); // Remove empty lines
|
||||
|
||||
console.log('Attempting to extract prompts from CSV changes...');
|
||||
for (const line of addedLines) {
|
||||
// Parse CSV line considering escaped quotes
|
||||
const matches = [...line.matchAll(/"([^"]*(?:""[^"]*)*)"/g)];
|
||||
if (matches.length >= 2) {
|
||||
const actName = normalizeTitle(matches[0][1].replace(/""/g, '"').trim());
|
||||
const promptText = matches[1][1].replace(/""/g, '"').trim()
|
||||
.replace(/^(?:Contributed by:?[^\n]*\n\s*)+/i, '')
|
||||
.trim();
|
||||
|
||||
const contributorInfo = `Contributed by: [@${pr.user.login}](https://github.com/${pr.user.login})`;
|
||||
prompts.set(actName.toLowerCase(), { actName, promptText, contributorInfo });
|
||||
console.log(`Found prompt in CSV: "${actName}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (prompts.size === 0) {
|
||||
console.log('Failed to extract prompt information');
|
||||
await octokit.issues.createComment({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
issue_number: issueNumber,
|
||||
body: '❌ Could not extract prompt information from changes'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get content from main branch
|
||||
console.log('Fetching current content from main branch...');
|
||||
const { data: readmeFile } = await octokit.repos.getContent({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
path: 'README.md',
|
||||
ref: 'main'
|
||||
});
|
||||
|
||||
const { data: csvFile } = await octokit.repos.getContent({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
path: 'prompts.csv',
|
||||
ref: 'main'
|
||||
});
|
||||
|
||||
// Prepare new content
|
||||
console.log('Preparing content updates...');
|
||||
let readmeContent = Buffer.from(readmeFile.content, 'base64').toString('utf-8');
|
||||
let csvContent = Buffer.from(csvFile.content, 'base64').toString('utf-8');
|
||||
if (!csvContent.endsWith('\n')) csvContent += '\n';
|
||||
|
||||
// Convert Map to array for processing
|
||||
const promptsArray = Array.from(prompts.values());
|
||||
|
||||
// Process each prompt
|
||||
for (const { actName, promptText, contributorInfo } of promptsArray) {
|
||||
// Remove markdown quote character and trim whitespace
|
||||
const cleanPrompt = promptText.replace(/^>\s*/gm, '').trim();
|
||||
|
||||
// For README: Add quote to each line
|
||||
const readmePrompt = cleanPrompt.split('\n')
|
||||
.map(line => `> ${line.trim()}`)
|
||||
.join('\n');
|
||||
const newSection = `## ${actName}\n${contributorInfo}\n\n${readmePrompt}\n\n`;
|
||||
|
||||
// For CSV: Convert to single paragraph
|
||||
const csvPrompt = cleanPrompt.replace(/\n+/g, ' ').trim();
|
||||
|
||||
// Insert the new section before Contributors in README
|
||||
const contributorsIndex = readmeContent.indexOf('## Contributors');
|
||||
if (contributorsIndex === -1) {
|
||||
console.log('Contributors section not found, appending to end');
|
||||
readmeContent += newSection;
|
||||
} else {
|
||||
console.log('Inserting before Contributors section');
|
||||
readmeContent = readmeContent.slice(0, contributorsIndex) + newSection + readmeContent.slice(contributorsIndex);
|
||||
}
|
||||
|
||||
// Add to CSV content
|
||||
csvContent += `"${actName.replace(/^Act as an?/i, '').replace(/"/g, '""')}","${csvPrompt.replace(/"/g, '""')}"\n`;
|
||||
}
|
||||
|
||||
// Create new branch
|
||||
const branchName = `prompt/${promptsArray.map(p => p.actName.toLowerCase().replace(/[^a-z0-9]+/g, '-')).join('-')}`;
|
||||
console.log(`Creating new branch: ${branchName}`);
|
||||
|
||||
// Check if branch exists and delete it
|
||||
try {
|
||||
console.log('Checking if branch already exists...');
|
||||
const { data: existingRef } = await octokit.git.getRef({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
ref: `heads/${branchName}`
|
||||
});
|
||||
|
||||
if (existingRef) {
|
||||
// Check for existing PRs from this branch
|
||||
console.log('Checking for existing PRs from this branch...');
|
||||
const { data: existingPRs } = await octokit.pulls.list({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
head: `${event.repository.owner.login}:${branchName}`,
|
||||
state: 'open'
|
||||
});
|
||||
|
||||
// Close any existing PRs
|
||||
for (const pr of existingPRs) {
|
||||
console.log(`Closing existing PR #${pr.number}...`);
|
||||
await octokit.pulls.update({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Branch exists, deleting it...');
|
||||
await octokit.git.deleteRef({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
ref: `heads/${branchName}`
|
||||
});
|
||||
console.log('Existing branch deleted');
|
||||
}
|
||||
} catch (error) {
|
||||
// 404 means branch doesn't exist, which is fine
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
console.log('Branch does not exist, proceeding with creation');
|
||||
}
|
||||
|
||||
// Get main branch ref
|
||||
const { data: mainRef } = await octokit.git.getRef({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
ref: 'heads/main'
|
||||
});
|
||||
|
||||
// Create new branch
|
||||
await octokit.git.createRef({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha: mainRef.object.sha
|
||||
});
|
||||
|
||||
// Get current files from the new branch
|
||||
console.log('Getting current file SHAs...');
|
||||
const { data: currentReadme } = await octokit.repos.getContent({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
path: 'README.md',
|
||||
ref: branchName
|
||||
});
|
||||
|
||||
const { data: currentCsv } = await octokit.repos.getContent({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
path: 'prompts.csv',
|
||||
ref: branchName
|
||||
});
|
||||
|
||||
// Update files with correct author
|
||||
console.log('Updating README.md...');
|
||||
await octokit.repos.createOrUpdateFileContents({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
path: 'README.md',
|
||||
message: promptsArray.length === 1
|
||||
? `feat: Add "${promptsArray[0].actName}" to README`
|
||||
: `feat: Add multiple prompts to README`,
|
||||
content: Buffer.from(readmeContent).toString('base64'),
|
||||
branch: branchName,
|
||||
sha: currentReadme.sha,
|
||||
committer: {
|
||||
name: pr.user.login,
|
||||
email: `${pr.user.login}@users.noreply.github.com`
|
||||
},
|
||||
author: {
|
||||
name: pr.user.login,
|
||||
email: `${pr.user.login}@users.noreply.github.com`
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Updating prompts.csv...');
|
||||
await octokit.repos.createOrUpdateFileContents({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
path: 'prompts.csv',
|
||||
message: promptsArray.length === 1
|
||||
? `feat: Add "${promptsArray[0].actName}" to prompts.csv`
|
||||
: `feat: Add multiple prompts to prompts.csv`,
|
||||
content: Buffer.from(csvContent).toString('base64'),
|
||||
branch: branchName,
|
||||
sha: currentCsv.sha,
|
||||
committer: {
|
||||
name: pr.user.login,
|
||||
email: `${pr.user.login}@users.noreply.github.com`
|
||||
},
|
||||
author: {
|
||||
name: pr.user.login,
|
||||
email: `${pr.user.login}@users.noreply.github.com`
|
||||
}
|
||||
});
|
||||
|
||||
// Create new PR
|
||||
const prTitle = promptsArray.length === 1
|
||||
? `feat: Add "${promptsArray[0].actName}"`
|
||||
: `feat: Add multiple prompts (${promptsArray.map(p => `"${p.actName}"`).join(', ')})`;
|
||||
|
||||
const prBody = promptsArray.length === 1
|
||||
? `This PR supersedes #${issueNumber} with proper formatting. Original PR by @${pr.user.login}. Added "${promptsArray[0].actName}" to README.md and prompts.csv, preserving original attribution.`
|
||||
: `This PR supersedes #${issueNumber} with proper formatting. Original PR by @${pr.user.login}.\n\nAdded the following prompts:\n${promptsArray.map(p => `- "${p.actName}"`).join('\n')}\n\nAll prompts have been added to README.md and prompts.csv, preserving original attribution.`;
|
||||
|
||||
const { data: newPr } = await octokit.pulls.create({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
title: prTitle,
|
||||
head: branchName,
|
||||
base: 'main',
|
||||
body: prBody
|
||||
});
|
||||
|
||||
// Comment on original PR
|
||||
await octokit.issues.createComment({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
issue_number: issueNumber,
|
||||
body: `I've created a new PR #${newPr.number} with your contribution properly formatted. This PR will be closed in favor of the new one.`
|
||||
});
|
||||
|
||||
// Close original PR
|
||||
await octokit.pulls.update({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
pull_number: issueNumber,
|
||||
state: 'closed'
|
||||
});
|
||||
|
||||
console.log(`Created new PR #${newPr.number} and closed original PR #${issueNumber}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error details:', error);
|
||||
await octokit.issues.createComment({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
issue_number: issueNumber,
|
||||
body: `❌ Error while trying to create new PR:\n\`\`\`\n${error.message}\n\`\`\``
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle rename command specifically
|
||||
if (aiCommand.startsWith('rename') || aiCommand === 'suggest title') {
|
||||
if (!isPullRequest) {
|
||||
await octokit.issues.createComment({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
issue_number: issueNumber,
|
||||
body: '❌ The rename command can only be used on pull requests.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get PR details for context
|
||||
const { data: pr } = await octokit.pulls.get({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
pull_number: issueNumber
|
||||
});
|
||||
|
||||
// Get the list of files changed in the PR
|
||||
const { data: files } = await octokit.pulls.listFiles({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
pull_number: issueNumber
|
||||
});
|
||||
|
||||
// Process file changes
|
||||
const fileChanges = await Promise.all(files.map(async file => {
|
||||
if (file.status === 'removed') {
|
||||
return `Deleted: ${file.filename}`;
|
||||
}
|
||||
|
||||
// Get file content for added or modified files
|
||||
if (file.status === 'added' || file.status === 'modified') {
|
||||
const patch = file.patch || '';
|
||||
return `${file.status === 'added' ? 'Added' : 'Modified'}: ${file.filename}\nChanges:\n${patch}`;
|
||||
}
|
||||
|
||||
return `${file.status}: ${file.filename}`;
|
||||
}));
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant that generates clear and concise pull request titles. Follow these rules:\n1. Use conventional commit style (feat:, fix:, docs:, etc.)\n2. Focus on WHAT changed, not HOW or WHERE\n3. Keep it short and meaningful\n4. Don't mention file names or technical implementation details\n5. Return ONLY the new title, nothing else\n\nGood examples:\n- feat: Add \"Act as a Career Coach\"\n- fix: Correct typo in Linux Terminal prompt\n- docs: Update installation instructions\n- refactor: Improve error handling"
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Based on these file changes, generate a concise PR title:\n\n${fileChanges.join('\n\n')}`
|
||||
}
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_tokens: 60
|
||||
});
|
||||
|
||||
const newTitle = completion.choices[0].message.content.trim();
|
||||
|
||||
// Update PR title
|
||||
await octokit.pulls.update({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
pull_number: issueNumber,
|
||||
title: newTitle
|
||||
});
|
||||
|
||||
// Add comment about the rename
|
||||
await octokit.issues.createComment({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
issue_number: issueNumber,
|
||||
body: `✨ Updated PR title to: "${newTitle}"\n\nBased on the following changes:\n\`\`\`diff\n${fileChanges.join('\n')}\n\`\`\``
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle other commands
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful AI assistant that helps with GitHub repositories. You can suggest code changes, fix issues, and improve code quality."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: aiCommand
|
||||
}
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000
|
||||
});
|
||||
|
||||
const response = completion.choices[0].message.content;
|
||||
|
||||
// If response contains code changes, create a new branch and PR
|
||||
if (response.includes('```')) {
|
||||
const branchName = `ai-bot/fix-${issueNumber}`;
|
||||
|
||||
// Create new branch
|
||||
const defaultBranch = event.repository.default_branch;
|
||||
const ref = await octokit.git.getRef({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
ref: `heads/${defaultBranch}`
|
||||
});
|
||||
|
||||
await octokit.git.createRef({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha: ref.data.object.sha
|
||||
});
|
||||
|
||||
// Extract code changes and file paths from response
|
||||
const codeBlocks = response.match(/```[\s\S]*?```/g);
|
||||
for (const block of codeBlocks) {
|
||||
const [_, filePath, ...codeLines] = block.split('\n');
|
||||
const content = Buffer.from(codeLines.join('\n')).toString('base64');
|
||||
|
||||
await octokit.repos.createOrUpdateFileContents({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
path: filePath,
|
||||
message: `AI Bot: Apply suggested changes for #${issueNumber}`,
|
||||
content,
|
||||
branch: branchName
|
||||
});
|
||||
}
|
||||
|
||||
// Create PR
|
||||
await octokit.pulls.create({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
title: `AI Bot: Fix for #${issueNumber}`,
|
||||
body: `This PR was automatically generated in response to #${issueNumber}\n\nChanges proposed:\n${response}`,
|
||||
head: branchName,
|
||||
base: defaultBranch
|
||||
});
|
||||
}
|
||||
|
||||
// Add comment with response
|
||||
await octokit.issues.createComment({
|
||||
owner: event.repository.owner.login,
|
||||
repo: event.repository.name,
|
||||
issue_number: issueNumber,
|
||||
body: `AI Bot Response:\n\n${response}`
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
EOF
|
||||
|
||||
- name: Handle errors
|
||||
if: failure()
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const issueNumber = context.issue.number || context.payload.pull_request.number;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: '❌ Sorry, there was an error processing your command. Please try again or contact the repository maintainers.'
|
||||
});
|
||||
43
.github/workflows/auto_commands.yml
vendored
43
.github/workflows/auto_commands.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Auto AI Commands
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-and-comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Check PR status and comment
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Check if PR has conflicts
|
||||
if (pr.mergeable === false) {
|
||||
console.log('PR has conflicts, commenting /ai resolve');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: '/ai resolve'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if PR title starts with "updated"
|
||||
if (pr.title.toLowerCase().startsWith('updated')) {
|
||||
console.log('PR title starts with "updated", commenting /ai suggest title');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: '/ai suggest title'
|
||||
});
|
||||
}
|
||||
82
.github/workflows/csv_linter.yml
vendored
82
.github/workflows/csv_linter.yml
vendored
@@ -1,82 +0,0 @@
|
||||
name: CSV Linter and Trailing Whitespaces
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint_and_check_trailing_whitespaces:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.8"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install csvkit
|
||||
|
||||
- name: Validate CSV structure
|
||||
run: |
|
||||
echo "Checking CSV structure..."
|
||||
if ! csvclean -n prompts.csv 2>&1 | tee /tmp/csv_errors.log; then
|
||||
echo "::error::CSV validation failed"
|
||||
cat /tmp/csv_errors.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check CSV format
|
||||
run: |
|
||||
echo "Checking CSV format..."
|
||||
if ! python -c '
|
||||
import csv
|
||||
with open("prompts.csv", "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
headers = next(reader)
|
||||
if headers != ["act", "prompt", "for_devs", "type"]:
|
||||
print("Error: CSV headers must be exactly [act, prompt, for_devs, type]")
|
||||
exit(1)
|
||||
valid_types = ["TEXT", "JSON"]
|
||||
for row_num, row in enumerate(reader, 2):
|
||||
if len(row) != 4:
|
||||
print(f"Error: Row {row_num} has {len(row)} columns, expected 4")
|
||||
exit(1)
|
||||
if not row[0] or not row[1] or not row[2] or not row[3]:
|
||||
print(f"Error: Row {row_num} has empty values")
|
||||
exit(1)
|
||||
if row[3] not in valid_types:
|
||||
print(f"Error: Row {row_num} has invalid type \"{row[3]}\". Must be TEXT or JSON")
|
||||
exit(1)
|
||||
print("CSV format OK")
|
||||
'; then
|
||||
echo "::error::CSV format check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check Trailing Whitespaces
|
||||
run: |
|
||||
echo "Checking for trailing whitespaces..."
|
||||
if grep -q "[[:space:]]$" prompts.csv; then
|
||||
echo "::error::Found trailing whitespaces in prompts.csv"
|
||||
grep -n "[[:space:]]$" prompts.csv | while read -r line; do
|
||||
echo "Line with trailing whitespace: $line"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
echo "No trailing whitespaces found"
|
||||
|
||||
- name: Check for UTF-8 BOM and line endings
|
||||
run: |
|
||||
echo "Checking for UTF-8 BOM and line endings..."
|
||||
if file prompts.csv | grep -q "with BOM"; then
|
||||
echo "::error::File contains UTF-8 BOM marker"
|
||||
exit 1
|
||||
fi
|
||||
if file prompts.csv | grep -q "CRLF"; then
|
||||
echo "::error::File contains Windows-style (CRLF) line endings"
|
||||
exit 1
|
||||
fi
|
||||
29
.github/workflows/publish.yml
vendored
29
.github/workflows/publish.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: Publish to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.2"
|
||||
bundler-cache: true
|
||||
|
||||
- name: Build site
|
||||
run: bundle exec jekyll build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site
|
||||
48
.gitignore
vendored
48
.gitignore
vendored
@@ -1,4 +1,46 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
_site/
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
.cursorrules
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy necessary files from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Run database migrations and start the server
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]
|
||||
4
Gemfile
4
Gemfile
@@ -1,4 +0,0 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "jekyll"
|
||||
gem "github-pages", group: :jekyll_plugins
|
||||
309
Gemfile.lock
309
Gemfile.lock
@@ -1,309 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
activesupport (8.1.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (3.3.1)
|
||||
coffee-script (2.4.1)
|
||||
coffee-script-source
|
||||
execjs
|
||||
coffee-script-source (1.12.2)
|
||||
colorator (1.1.0)
|
||||
commonmarker (0.23.12)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.5)
|
||||
csv (3.3.5)
|
||||
dnsruby (1.73.1)
|
||||
base64 (>= 0.2)
|
||||
logger (~> 1.6)
|
||||
simpleidn (~> 0.2.1)
|
||||
drb (2.2.3)
|
||||
em-websocket (0.5.3)
|
||||
eventmachine (>= 0.12.9)
|
||||
http_parser.rb (~> 0)
|
||||
ethon (0.15.0)
|
||||
ffi (>= 1.15.0)
|
||||
eventmachine (1.2.7)
|
||||
execjs (2.10.0)
|
||||
faraday (2.14.0)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
ffi (1.17.2-arm-linux-musl)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
forwardable-extended (2.6.0)
|
||||
gemoji (4.1.0)
|
||||
github-pages (232)
|
||||
github-pages-health-check (= 1.18.2)
|
||||
jekyll (= 3.10.0)
|
||||
jekyll-avatar (= 0.8.0)
|
||||
jekyll-coffeescript (= 1.2.2)
|
||||
jekyll-commonmark-ghpages (= 0.5.1)
|
||||
jekyll-default-layout (= 0.1.5)
|
||||
jekyll-feed (= 0.17.0)
|
||||
jekyll-gist (= 1.5.0)
|
||||
jekyll-github-metadata (= 2.16.1)
|
||||
jekyll-include-cache (= 0.2.1)
|
||||
jekyll-mentions (= 1.6.0)
|
||||
jekyll-optional-front-matter (= 0.3.2)
|
||||
jekyll-paginate (= 1.1.0)
|
||||
jekyll-readme-index (= 0.3.0)
|
||||
jekyll-redirect-from (= 0.16.0)
|
||||
jekyll-relative-links (= 0.6.1)
|
||||
jekyll-remote-theme (= 0.4.3)
|
||||
jekyll-sass-converter (= 1.5.2)
|
||||
jekyll-seo-tag (= 2.8.0)
|
||||
jekyll-sitemap (= 1.4.0)
|
||||
jekyll-swiss (= 1.0.0)
|
||||
jekyll-theme-architect (= 0.2.0)
|
||||
jekyll-theme-cayman (= 0.2.0)
|
||||
jekyll-theme-dinky (= 0.2.0)
|
||||
jekyll-theme-hacker (= 0.2.0)
|
||||
jekyll-theme-leap-day (= 0.2.0)
|
||||
jekyll-theme-merlot (= 0.2.0)
|
||||
jekyll-theme-midnight (= 0.2.0)
|
||||
jekyll-theme-minimal (= 0.2.0)
|
||||
jekyll-theme-modernist (= 0.2.0)
|
||||
jekyll-theme-primer (= 0.6.0)
|
||||
jekyll-theme-slate (= 0.2.0)
|
||||
jekyll-theme-tactile (= 0.2.0)
|
||||
jekyll-theme-time-machine (= 0.2.0)
|
||||
jekyll-titles-from-headings (= 0.5.3)
|
||||
jemoji (= 0.13.0)
|
||||
kramdown (= 2.4.0)
|
||||
kramdown-parser-gfm (= 1.1.0)
|
||||
liquid (= 4.0.4)
|
||||
mercenary (~> 0.3)
|
||||
minima (= 2.5.1)
|
||||
nokogiri (>= 1.16.2, < 2.0)
|
||||
rouge (= 3.30.0)
|
||||
terminal-table (~> 1.4)
|
||||
webrick (~> 1.8)
|
||||
github-pages-health-check (1.18.2)
|
||||
addressable (~> 2.3)
|
||||
dnsruby (~> 1.60)
|
||||
octokit (>= 4, < 8)
|
||||
public_suffix (>= 3.0, < 6.0)
|
||||
typhoeus (~> 1.3)
|
||||
html-pipeline (2.14.3)
|
||||
activesupport (>= 2)
|
||||
nokogiri (>= 1.4)
|
||||
http_parser.rb (0.8.0)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jekyll (3.10.0)
|
||||
addressable (~> 2.4)
|
||||
colorator (~> 1.0)
|
||||
csv (~> 3.0)
|
||||
em-websocket (~> 0.5)
|
||||
i18n (>= 0.7, < 2)
|
||||
jekyll-sass-converter (~> 1.0)
|
||||
jekyll-watch (~> 2.0)
|
||||
kramdown (>= 1.17, < 3)
|
||||
liquid (~> 4.0)
|
||||
mercenary (~> 0.3.3)
|
||||
pathutil (~> 0.9)
|
||||
rouge (>= 1.7, < 4)
|
||||
safe_yaml (~> 1.0)
|
||||
webrick (>= 1.0)
|
||||
jekyll-avatar (0.8.0)
|
||||
jekyll (>= 3.0, < 5.0)
|
||||
jekyll-coffeescript (1.2.2)
|
||||
coffee-script (~> 2.2)
|
||||
coffee-script-source (~> 1.12)
|
||||
jekyll-commonmark (1.4.0)
|
||||
commonmarker (~> 0.22)
|
||||
jekyll-commonmark-ghpages (0.5.1)
|
||||
commonmarker (>= 0.23.7, < 1.1.0)
|
||||
jekyll (>= 3.9, < 4.0)
|
||||
jekyll-commonmark (~> 1.4.0)
|
||||
rouge (>= 2.0, < 5.0)
|
||||
jekyll-default-layout (0.1.5)
|
||||
jekyll (>= 3.0, < 5.0)
|
||||
jekyll-feed (0.17.0)
|
||||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-gist (1.5.0)
|
||||
octokit (~> 4.2)
|
||||
jekyll-github-metadata (2.16.1)
|
||||
jekyll (>= 3.4, < 5.0)
|
||||
octokit (>= 4, < 7, != 4.4.0)
|
||||
jekyll-include-cache (0.2.1)
|
||||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-mentions (1.6.0)
|
||||
html-pipeline (~> 2.3)
|
||||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-optional-front-matter (0.3.2)
|
||||
jekyll (>= 3.0, < 5.0)
|
||||
jekyll-paginate (1.1.0)
|
||||
jekyll-readme-index (0.3.0)
|
||||
jekyll (>= 3.0, < 5.0)
|
||||
jekyll-redirect-from (0.16.0)
|
||||
jekyll (>= 3.3, < 5.0)
|
||||
jekyll-relative-links (0.6.1)
|
||||
jekyll (>= 3.3, < 5.0)
|
||||
jekyll-remote-theme (0.4.3)
|
||||
addressable (~> 2.0)
|
||||
jekyll (>= 3.5, < 5.0)
|
||||
jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0)
|
||||
rubyzip (>= 1.3.0, < 3.0)
|
||||
jekyll-sass-converter (1.5.2)
|
||||
sass (~> 3.4)
|
||||
jekyll-seo-tag (2.8.0)
|
||||
jekyll (>= 3.8, < 5.0)
|
||||
jekyll-sitemap (1.4.0)
|
||||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-swiss (1.0.0)
|
||||
jekyll-theme-architect (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-cayman (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-dinky (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-hacker (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-leap-day (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-merlot (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-midnight (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-minimal (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-modernist (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-primer (0.6.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-github-metadata (~> 2.9)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-slate (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-tactile (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-time-machine (0.2.0)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-titles-from-headings (0.5.3)
|
||||
jekyll (>= 3.3, < 5.0)
|
||||
jekyll-watch (2.2.1)
|
||||
listen (~> 3.0)
|
||||
jemoji (0.13.0)
|
||||
gemoji (>= 3, < 5)
|
||||
html-pipeline (~> 2.2)
|
||||
jekyll (>= 3.0, < 5.0)
|
||||
json (2.16.0)
|
||||
kramdown (2.4.0)
|
||||
rexml
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
liquid (4.0.4)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.7.0)
|
||||
mercenary (0.3.6)
|
||||
minima (2.5.1)
|
||||
jekyll (>= 3.5, < 5.0)
|
||||
jekyll-feed (~> 0.9)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
minitest (5.26.2)
|
||||
net-http (0.8.0)
|
||||
uri (>= 0.11.1)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octokit (4.25.1)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pathutil (0.16.2)
|
||||
forwardable-extended (~> 2.6)
|
||||
public_suffix (5.1.1)
|
||||
racc (1.8.1)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.30.0)
|
||||
rubyzip (2.4.1)
|
||||
safe_yaml (1.0.5)
|
||||
sass (3.7.4)
|
||||
sass-listen (~> 4.0.0)
|
||||
sass-listen (4.0.0)
|
||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
sawyer (0.9.3)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.1)
|
||||
simpleidn (0.2.3)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
typhoeus (1.5.0)
|
||||
ethon (>= 0.9.0, < 0.16.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (1.8.0)
|
||||
uri (1.1.1)
|
||||
webrick (1.9.2)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux-gnu
|
||||
aarch64-linux-musl
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86_64-darwin
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
github-pages
|
||||
jekyll
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.23
|
||||
@@ -1,7 +0,0 @@
|
||||
name: prompts.chat
|
||||
title: prompts.chat
|
||||
subtitle: World's First & Most Famous Prompts Directory
|
||||
|
||||
github_pages:
|
||||
url: "https://prompts.chat"
|
||||
branch: "main"
|
||||
@@ -1,380 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ page.lang | default: site.lang | default: "en-US" }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>{{ page.title | default: site.title }} — awesome AI prompts</title>
|
||||
{% seo %}
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ "/style.css?v=" | append: site.github.build_revision | relative_url }}">
|
||||
</head>
|
||||
<body class="{{ page.body_class | default: '' }}">
|
||||
<div class="layout-wrapper">
|
||||
<!-- Copilot Suggestion Modal Backdrop -->
|
||||
<div class="copilot-suggestion-backdrop"></div>
|
||||
<!-- Copilot Suggestion Modal -->
|
||||
<div class="copilot-suggestion-modal" id="copilotSuggestionModal">
|
||||
<div class="copilot-suggestion-content">
|
||||
GitHub Copilot may work better with developer mode. Would you like to switch to GitHub Copilot?
|
||||
</div>
|
||||
<div class="copilot-suggestion-actions">
|
||||
<div class="copilot-suggestion-buttons">
|
||||
<button class="copilot-suggestion-button secondary" onclick="hideCopilotSuggestion(false)">No, thanks</button>
|
||||
<button class="copilot-suggestion-button primary" onclick="hideCopilotSuggestion(true)">Switch to GitHub Copilot</button>
|
||||
</div>
|
||||
<label class="copilot-suggestion-checkbox">
|
||||
<input type="checkbox" id="doNotShowAgain">
|
||||
Don't show again
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<header class="site-header">
|
||||
<div class="header-left">
|
||||
<h1 class="site-title">{{ page.title | default: site.title }}</h1>
|
||||
<p class="site-slogan">{{ page.subtitle | default: site.subtitle }}
|
||||
{% if page.subpage != true %}
|
||||
<a href="/vibe" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 transition-colors">
|
||||
Vibe Coding Prompts
|
||||
</a>
|
||||
<a href="/sponsors" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 transition-colors">
|
||||
♥️ Improve Your GitHub Sponsors Profile
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if page.hide_platform_selector != true %}
|
||||
<div class="site-description">
|
||||
<p class="platform-hint">Choose your AI platform</p>
|
||||
<div class="platform-pills">
|
||||
<button class="platform-tag" data-platform="github-copilot" data-url="https://github.com/copilot">GitHub Copilot</button>
|
||||
<button class="platform-tag" data-platform="chatgpt" data-url="https://chat.openai.com">ChatGPT</button>
|
||||
<div class="platform-tag-container">
|
||||
<button class="platform-tag" data-platform="grok" data-url="https://grok.com/chat?reasoningMode=none">Grok</button>
|
||||
<div class="grok-mode-dropdown" style="display: none;">
|
||||
<div class="grok-mode-option" data-url="https://grok.com/chat?reasoningMode=none">Grok</div>
|
||||
<div class="grok-mode-option" data-url="https://grok.com/chat?reasoningMode=deepsearch">Grok Deep Search</div>
|
||||
<div class="grok-mode-option" data-url="https://grok.com/chat?reasoningMode=think">Grok Thinking</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="platform-tag" data-platform="claude" data-url="https://claude.ai/new">Claude</button>
|
||||
<button class="platform-tag" data-platform="perplexity" data-url="https://perplexity.ai">Perplexity</button>
|
||||
<button class="platform-tag" data-platform="mistral" data-url="https://chat.mistral.ai/chat">Mistral</button>
|
||||
<button class="platform-tag" data-platform="gemini" data-url="https://gemini.google.com">Gemini</button>
|
||||
<button class="platform-tag" data-platform="llama" data-url="https://meta.ai">Meta</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a href="https://cursor.com" target="_blank" class="cursor-logo" title="Built with Cursor AI">
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z" fill="#10b981" class="cursor-logo-path dark-mode-path"></path>
|
||||
<path d="M22.35 18V6L11.925 0v12l10.425 6z" fill="#10b981" class="cursor-logo-path dark-mode-path"></path>
|
||||
<path d="M11.925 0L1.5 6v12l10.425-6V0z" fill="#10b981" class="cursor-logo-path dark-mode-path"></path>
|
||||
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="#fff" class="cursor-logo-path dark-mode-path"></path>
|
||||
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="#fff" class="cursor-logo-path dark-mode-path"></path>
|
||||
</svg>
|
||||
<span>vibecoded with cursor</span>
|
||||
</a>
|
||||
<a href="https://github.com/f/awesome-chatgpt-prompts/stargazers" target="_blank" class="star-count">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</svg>
|
||||
<span id="starCount">...</span>
|
||||
</a>
|
||||
<a href="https://chromewebstore.google.com/detail/promptschat/eemdohkhbaifiocagjlhibfbhamlbeej" target="_blank" class="chrome-ext-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<line x1="21.17" y1="8" x2="12" y2="8"></line>
|
||||
<line x1="3.95" y1="6.06" x2="8.54" y2="14"></line>
|
||||
<line x1="10.88" y1="21.94" x2="15.46" y2="14"></line>
|
||||
</svg>
|
||||
</a>
|
||||
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle dark mode">
|
||||
<svg class="mode-icon sun-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
||||
<svg class="mode-icon moon-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<button class="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="true">
|
||||
<svg class="sidebar-toggle-icon collapse-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="11 17 6 12 11 7"></polyline><polyline points="18 17 13 12 18 7"></polyline></svg>
|
||||
<svg class="sidebar-toggle-icon expand-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><polyline points="13 17 18 12 13 7"></polyline><polyline points="6 17 11 12 6 7"></polyline></svg>
|
||||
</button>
|
||||
<div class="sidebar">
|
||||
<div class="search-container">
|
||||
<div class="prompt-count" id="promptCount">
|
||||
<span class="count-label">All Prompts</span>
|
||||
<span class="prompts-count-label">Developer Prompts</span>
|
||||
<span class="count-number">0</span>
|
||||
</div>
|
||||
<input type="text" id="searchInput" placeholder="Search prompts...">
|
||||
<ul id="searchResults"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
{% if page.hide_platform_selector != true %}
|
||||
<div class="main-content-header">
|
||||
<div class="header-content">
|
||||
<div class="platform-selectors">
|
||||
Reply in <select id="languageSelect" class="custom-select">
|
||||
<option value="English">English</option>
|
||||
<option value="Spanish">Spanish</option>
|
||||
<option value="French">French</option>
|
||||
<option value="German">German</option>
|
||||
<option value="Italian">Italian</option>
|
||||
<option value="Portuguese">Portuguese</option>
|
||||
<option value="Russian">Russian</option>
|
||||
<option value="Chinese">Chinese</option>
|
||||
<option value="Japanese">Japanese</option>
|
||||
<option value="Korean">Korean</option>
|
||||
<option value="Turkish">Turkish</option>
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<input type="text" id="customLanguage" class="custom-input" placeholder="language..." style="display: none;">
|
||||
using <select id="toneSelect" class="custom-select">
|
||||
<option value="professional">professional</option>
|
||||
<option value="casual">casual</option>
|
||||
<option value="friendly">friendly</option>
|
||||
<option value="formal">formal</option>
|
||||
<option value="technical">technical</option>
|
||||
<option value="creative">creative</option>
|
||||
<option value="enthusiastic">enthusiastic</option>
|
||||
<option value="humorous">humorous</option>
|
||||
<option value="authoritative">authoritative</option>
|
||||
<option value="empathetic">empathetic</option>
|
||||
<option value="analytical">analytical</option>
|
||||
<option value="conversational">conversational</option>
|
||||
<option value="academic">academic</option>
|
||||
<option value="persuasive">persuasive</option>
|
||||
<option value="inspirational">inspirational</option>
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<input type="text" id="customTone" class="custom-input" placeholder="tone..." style="display: none;">
|
||||
tone, for <select id="audienceSelect" class="custom-select">
|
||||
<option value="everyone">everyone</option>
|
||||
<option value="developers">developers</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="container-lg markdown-body">
|
||||
<div id="promptContent">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h3>About</h3>
|
||||
<p>A curated collection of effective prompts for ChatGPT and other AI assistants, curated by <a href="https://x.com/fkadev">Fatih Kadir Akın</a>. While designed for ChatGPT, these prompts can be adapted for Claude, Gemini, Llama, and other language models to help you get more out of AI interactions.</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h3>Contributing</h3>
|
||||
<p>If you'd like to contribute, please fork the repository and make changes as you'd like. Pull requests are warmly welcome. Please read the <a href="https://github.com/f/awesome-chatgpt-prompts/blob/main/CONTRIBUTING.md" style="color: var(--accent-color);">contribution guidelines</a> first.</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h3>Links</h3>
|
||||
<div class="book-links">
|
||||
<a href="https://www.forbes.com/sites/tjmccue/2023/01/19/chatgpt-success-completely-depends-on-your-prompt/?sh=497a92a21a16" target="_blank" class="book-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
Featured on Forbes
|
||||
</a>
|
||||
<a href="https://github.com/f/awesome-chatgpt-prompts" target="_blank" class="book-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg>
|
||||
GitHub Repository
|
||||
</a>
|
||||
<a href="https://huggingface.co/datasets/fka/awesome-chatgpt-prompts" target="_blank" class="book-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
<line x1="7" y1="15" x2="7" y2="19"></line>
|
||||
<line x1="11" y1="15" x2="11" y2="19"></line>
|
||||
<line x1="15" y1="15" x2="15" y2="19"></line>
|
||||
</svg>
|
||||
Hugging Face Dataset
|
||||
</a>
|
||||
<a href="https://github.com/f/awesome-chatgpt-prompts/pulls" target="_blank" class="book-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="16"></line>
|
||||
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||
</svg>
|
||||
View Unmerged Prompts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h3>e-Books by @f</h3>
|
||||
<div class="book-links">
|
||||
<a href="https://fka.gumroad.com/l/art-of-chatgpt-prompting" class="book-link" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
|
||||
The Art of ChatGPT Prompting
|
||||
</a>
|
||||
<a href="https://fka.gumroad.com/l/how-to-make-money-with-chatgpt" class="book-link" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
|
||||
How to Make Money with ChatGPT
|
||||
</a>
|
||||
<a href="https://fka.gumroad.com/l/the-art-of-midjourney-ai-guide-to-creating-images-from-text" class="book-link" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
|
||||
The Art of Midjourney AI
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="script.js"></script>
|
||||
{% if page.hide_tone_selector != true %}
|
||||
<script>
|
||||
// Initialize audience selector
|
||||
const audienceSelect = document.getElementById('audienceSelect');
|
||||
|
||||
// Handle Grok platform selection
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const grokButton = document.querySelector('[data-platform="grok"]');
|
||||
const grokDropdown = document.querySelector('.grok-mode-dropdown');
|
||||
const grokOptions = document.querySelectorAll('.grok-mode-option');
|
||||
let isGrokDropdownVisible = false;
|
||||
|
||||
// Add event listeners for all platform buttons
|
||||
const platformButtons = document.querySelectorAll('.platform-tag');
|
||||
platformButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const platform = button.getAttribute('data-platform');
|
||||
// If platform is not github-copilot, set audience to "everyone"
|
||||
if (platform !== 'github-copilot') {
|
||||
audienceSelect.value = 'everyone';
|
||||
document.body.classList.remove('dev-mode');
|
||||
// Trigger filtering if needed
|
||||
if (typeof filterPrompts === 'function') {
|
||||
filterPrompts();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Hide dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.platform-tag-container')) {
|
||||
grokDropdown.style.display = 'none';
|
||||
isGrokDropdownVisible = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle dropdown
|
||||
grokButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
isGrokDropdownVisible = !isGrokDropdownVisible;
|
||||
grokDropdown.style.display = isGrokDropdownVisible ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Handle option selection
|
||||
grokOptions.forEach(option => {
|
||||
option.addEventListener('click', (e) => {
|
||||
const selectedUrl = option.dataset.url;
|
||||
const selectedText = option.textContent;
|
||||
grokButton.dataset.url = selectedUrl;
|
||||
grokButton.textContent = selectedText;
|
||||
grokDropdown.style.display = 'none';
|
||||
isGrokDropdownVisible = false;
|
||||
|
||||
// Also set audience to "everyone" for Grok options
|
||||
audienceSelect.value = 'everyone';
|
||||
document.body.classList.remove('dev-mode');
|
||||
// Trigger filtering if needed
|
||||
if (typeof filterPrompts === 'function') {
|
||||
filterPrompts();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Set initial state based on URL params or default
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialAudience = urlParams.get('audience') || 'everyone';
|
||||
audienceSelect.value = initialAudience;
|
||||
document.body.classList.toggle('dev-mode', initialAudience === 'developers');
|
||||
|
||||
// Handle audience changes
|
||||
audienceSelect.addEventListener('change', (e) => {
|
||||
const isDevMode = e.target.value === 'developers';
|
||||
document.body.classList.toggle('dev-mode', isDevMode);
|
||||
|
||||
// Trigger prompt filtering
|
||||
filterPrompts();
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
<style>
|
||||
video { max-width: 100% !important; }
|
||||
|
||||
/* Embed button styling */
|
||||
.modal-embed-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.modal-embed-button:hover {
|
||||
background-color: var(--accent-color-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-embed-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for modal buttons */
|
||||
@media (max-width: 640px) {
|
||||
.modal-footer-right {
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-embed-button {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-chat-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-MSNHFWTE77"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-MSNHFWTE77');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,201 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ page.title | default: 'Prompt Share' }} - {{ site.title }}</title>
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'dynamic-background': 'rgb(var(--background) / <alpha-value>)',
|
||||
'dynamic-foreground': 'rgb(var(--foreground) / <alpha-value>)',
|
||||
'dynamic-muted': 'rgb(var(--muted) / <alpha-value>)',
|
||||
'dynamic-muted-foreground': 'rgb(var(--muted-foreground) / <alpha-value>)',
|
||||
'dynamic-primary': 'rgb(var(--primary) / <alpha-value>)',
|
||||
'dynamic-accent': 'rgb(var(--accent) / <alpha-value>)',
|
||||
'dynamic-border': 'rgb(var(--border) / <alpha-value>)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ '/embed-preview-style.css' | relative_url }}">
|
||||
<link rel="icon" type="image/x-icon" href="{{ '/favicon.ico' | relative_url }}">
|
||||
<meta name="description" content="AI prompt viewer">
|
||||
<style>
|
||||
@keyframes flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.flash-animation {
|
||||
animation: flash 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.diff-enter {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.diff-exit {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-dynamic-background text-dynamic-foreground overflow-hidden">
|
||||
<!-- Viewer Mode -->
|
||||
<div id="viewer-mode" class="viewer-mode h-screen flex">
|
||||
<!-- Sidebar - File Tree -->
|
||||
<div id="file-sidebar" class="hidden w-40 sm:w-44 border-r border-dynamic-border bg-dynamic-muted/30 flex-shrink-0 overflow-y-auto custom-scrollbar">
|
||||
<div class="p-2 border-b border-dynamic-border">
|
||||
<h3 class="text-[10px] font-semibold text-dynamic-muted-foreground uppercase tracking-wider">Files</h3>
|
||||
</div>
|
||||
<div id="file-tree" class="p-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex flex-col p-2 sm:p-4 h-full overflow-hidden">
|
||||
<!-- Top Bar with Context Pills and Edit Button -->
|
||||
<div class="flex justify-between items-start gap-2 mb-1 sm:mb-2 flex-shrink-0">
|
||||
<!-- Context Pills -->
|
||||
<div id="context-pills" class="flex flex-wrap gap-1.5 empty:hidden flex-1 min-w-0 pr-2"></div>
|
||||
|
||||
<!-- Edit Button -->
|
||||
<button id="edit-button" class="p-1 text-dynamic-muted-foreground hover:text-dynamic-foreground transition-colors flex-shrink-0" title="Edit in designer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Prompt Interface with Floating Diff - Flex-1 to take remaining space -->
|
||||
<div class="flex-1 relative min-h-0 overflow-hidden">
|
||||
<!-- Floating Diff View (when enabled and viewport is compact) -->
|
||||
<div id="diff-view" class="hidden absolute top-2 left-2 right-2 z-10 diff-floating">
|
||||
<div class="bg-dynamic-background/95 backdrop-blur-sm border border-dynamic-border shadow-lg rounded-lg p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3 h-3 text-dynamic-primary flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V7.414A2 2 0 0017.414 6L14 2.586A2 2 0 0012.586 2H4zm2 4a1 1 0 011-1h4a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7zm-1 5a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span id="diff-filename" class="text-xs font-medium text-dynamic-foreground truncate"></span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<div class="flex rounded-md overflow-hidden">
|
||||
<button id="diff-accept-diff" class="px-2 py-0.5 bg-green-500 hover:bg-green-600 text-white text-[10px] font-medium transition-colors flex items-center gap-0.5">
|
||||
<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Accept</span>
|
||||
</button>
|
||||
<button id="diff-reject-diff" class="px-2 py-0.5 bg-red-500 hover:bg-red-600 text-white text-[10px] font-medium transition-colors flex items-center gap-0.5">
|
||||
<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Reject</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Collapse/Expand button -->
|
||||
<button id="toggle-diff" class="p-0.5 text-dynamic-muted-foreground hover:text-dynamic-foreground transition-colors" title="Toggle diff view">
|
||||
<svg class="w-3 h-3 transform transition-transform" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="diff-content" class="space-y-1 transition-all duration-300 overflow-hidden">
|
||||
<div id="diff-old-content" class="bg-red-50/70 dark:bg-red-950/20 p-2 rounded text-[10px] font-mono whitespace-pre-wrap text-red-700 dark:text-red-400 overflow-x-auto border border-red-200/50 dark:border-red-900/30 max-h-20 overflow-y-auto custom-scrollbar"></div>
|
||||
<div id="diff-new-content" class="bg-green-50/70 dark:bg-green-950/20 p-2 rounded text-[10px] font-mono whitespace-pre-wrap text-green-700 dark:text-green-400 overflow-x-auto border border-green-200/50 dark:border-green-900/30 max-h-20 overflow-y-auto custom-scrollbar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container for inline diff and prompt -->
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Inline Diff View (when enabled and viewport has space) -->
|
||||
<div id="diff-view-inline" class="hidden mb-2 flex-shrink-0">
|
||||
<div class="bg-dynamic-muted border border-dynamic-border rounded-lg p-2">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3 h-3 text-dynamic-primary flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V7.414A2 2 0 0017.414 6L14 2.586A2 2 0 0012.586 2H4zm2 4a1 1 0 011-1h4a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7zm-1 5a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span id="inline-diff-filename" class="text-xs font-medium text-dynamic-foreground truncate"></span>
|
||||
</div>
|
||||
<div class="flex rounded-md overflow-hidden">
|
||||
<button id="inline-accept-diff" class="px-2 py-0.5 bg-green-500 hover:bg-green-600 text-white text-[10px] font-medium transition-colors flex items-center gap-0.5">
|
||||
<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Accept</span>
|
||||
</button>
|
||||
<button id="inline-reject-diff" class="px-2 py-0.5 bg-red-500 hover:bg-red-600 text-white text-[10px] font-medium transition-colors flex items-center gap-0.5">
|
||||
<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Reject</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<div id="inline-old-content" class="bg-red-50/70 dark:bg-red-950/20 p-1 rounded text-[10px] font-mono whitespace-pre-wrap text-red-700 dark:text-red-400 overflow-x-auto border border-red-200/50 dark:border-red-900/30 max-h-20 overflow-y-auto custom-scrollbar"></div>
|
||||
<div id="inline-new-content" class="bg-green-50/70 dark:bg-green-950/20 p-1 rounded text-[10px] font-mono whitespace-pre-wrap text-green-700 dark:text-green-400 overflow-x-auto border border-green-200/50 dark:border-green-900/30 max-h-20 overflow-y-auto custom-scrollbar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Container - Full height of parent -->
|
||||
<div id="prompt-container" class="flex-1 bg-dynamic-muted border border-dynamic-border rounded-xl p-3 relative focus-within:border-dynamic-primary transition-colors flex flex-col min-h-0">
|
||||
<div id="prompt-text" class="flex-1 text-dynamic-foreground leading-relaxed whitespace-pre-wrap overflow-y-auto custom-scrollbar text-sm sm:text-base duration-300"></div>
|
||||
<div id="prompt-placeholder" class="text-dynamic-muted-foreground italic absolute top-3 left-3 pointer-events-none text-sm sm:text-base">← Enter your prompt on designer...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar - Always visible with reduced height -->
|
||||
<div class="flex justify-between items-center gap-2 mt-1 pt-1 flex-shrink-0">
|
||||
<!-- Settings Pills - Smaller -->
|
||||
<div id="settings-pills" class="flex gap-1 flex-wrap flex-1 min-w-0"></div>
|
||||
|
||||
<!-- Send Button - Smaller -->
|
||||
<button id="copy-button" class="w-7 h-7 sm:w-8 sm:h-8 bg-dynamic-primary text-white rounded-full flex items-center justify-center hover:opacity-90 transition-opacity flex-shrink-0 shadow-md" title="Send prompt">
|
||||
<svg width="14" height="14" class="sm:w-4 sm:h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification -->
|
||||
<div id="notification" class="fixed top-4 right-4 bg-dynamic-accent text-white px-4 py-2 rounded-lg font-medium opacity-0 transition-opacity z-50 pointer-events-none"></div>
|
||||
|
||||
<script src="{{ '/embed-preview-script.js' | relative_url }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,390 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="page-mode" content="{{ page.mode | default: 'designer' }}">
|
||||
<title>prompts.chat/embed — awesome AI prompts</title>
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'dynamic-background': 'rgb(var(--background) / <alpha-value>)',
|
||||
'dynamic-foreground': 'rgb(var(--foreground) / <alpha-value>)',
|
||||
'dynamic-muted': 'rgb(var(--muted) / <alpha-value>)',
|
||||
'dynamic-muted-foreground': 'rgb(var(--muted-foreground) / <alpha-value>)',
|
||||
'dynamic-primary': 'rgb(var(--primary) / <alpha-value>)',
|
||||
'dynamic-accent': 'rgb(var(--accent) / <alpha-value>)',
|
||||
'dynamic-border': 'rgb(var(--border) / <alpha-value>)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ '/embed-style.css' | relative_url }}">
|
||||
<link rel="icon" type="image/x-icon" href="{{ '/favicon.ico' | relative_url }}">
|
||||
<meta name="description" content="Design and customize embeddable AI prompts">
|
||||
<style>
|
||||
.checkerboard-bg {
|
||||
background-image:
|
||||
linear-gradient(45deg, rgba(0,0,0,0.06) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(0,0,0,0.06) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.06) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.06) 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
}
|
||||
|
||||
.dark .checkerboard-bg {
|
||||
background-color: #1e1e1e;
|
||||
background-image:
|
||||
linear-gradient(45deg, #353535 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #353535 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #353535 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #353535 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-dynamic-background text-dynamic-foreground overflow-hidden checkerboard-bg">
|
||||
<!-- Site Header -->
|
||||
<header class="site-header">
|
||||
<div class="header-left">
|
||||
<h1 class="site-title">prompts.chat/embed</h1>
|
||||
<p class="site-slogan hidden sm:block">Design and share beautiful AI prompts, for your website or blog.</p>
|
||||
<p class="site-slogan sm:hidden">Design and share AI prompts</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a href="https://blog.fka.dev/blog/2025-06-18-building-a-react-hook-with-ai-a-step-by-step-guide-using-vibe-an-example-post-that-uses-prompts-chat-embed/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center text-xs text-dynamic-muted-foreground hover:text-dynamic-foreground transition-colors mr-3 touch-target hidden sm:inline-flex"
|
||||
title="View example blog post using prompts.chat embed">
|
||||
View example blog post
|
||||
</a>
|
||||
<button class="dark-mode-toggle touch-target" onclick="toggleDarkMode()" title="Toggle dark mode">
|
||||
<svg class="mode-icon sun-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
||||
<svg class="mode-icon moon-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Designer Mode -->
|
||||
<div id="designer-mode" class="designer-content flex flex-col lg:flex-row min-h-0">
|
||||
<!-- Left Panel - Customization -->
|
||||
<div class="designer-panel w-full lg:w-80 flex flex-col h-auto lg:h-full overflow-hidden order-2 lg:order-1 flex-shrink-0">
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div class="p-3 lg:p-4 space-y-3">
|
||||
|
||||
<!-- CONTENT SECTION -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs font-semibold text-dynamic-foreground uppercase tracking-wider border-b border-dynamic-border pb-1">Prompt</h3>
|
||||
|
||||
<!-- Example Selector -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">Load Example</label>
|
||||
<select id="example-select" class="w-full p-2 bg-dynamic-muted border border-dynamic-border rounded text-xs focus-ring touch-target">
|
||||
<option value="">Choose an example...</option>
|
||||
<option value="vibe-coding">Vibe coding (no diff)</option>
|
||||
<option value="vibe-coding-diff">Vibe coding with diff</option>
|
||||
<option value="chatgpt">ChatGPT example</option>
|
||||
<option value="claude">Claude example</option>
|
||||
<option value="image-analysis">Image analysis</option>
|
||||
<option value="api-design">API design</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Context -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">Context</label>
|
||||
<input type="text" id="designer-context"
|
||||
class="w-full p-2 bg-dynamic-background border border-dynamic-border rounded text-xs focus-ring touch-target"
|
||||
placeholder="file.py, @web, @codebase, #image">
|
||||
</div>
|
||||
|
||||
<!-- Prompt Text -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">Prompt Text</label>
|
||||
<textarea id="designer-prompt"
|
||||
class="w-full p-2 bg-dynamic-background border border-dynamic-border rounded text-xs resize-none focus-ring custom-scrollbar touch-target"
|
||||
rows="6"
|
||||
placeholder="Enter your prompt..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI SETTINGS SECTION -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs font-semibold text-dynamic-foreground uppercase tracking-wider border-b border-dynamic-border pb-1">AI Settings</h3>
|
||||
|
||||
<!-- Model & Mode Grid -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">Model</label>
|
||||
<select id="designer-model" class="w-full p-2 bg-dynamic-background border border-dynamic-border rounded text-xs focus-ring touch-target">
|
||||
<option value="o3">o3</option>
|
||||
<option value="GPT 4.1">GPT 4.1</option>
|
||||
<option value="GPT 4o" selected>GPT 4o</option>
|
||||
<option value="Claude 3.7 Sonnet">Claude 3.7 Sonnet</option>
|
||||
<option value="Claude 4 Sonnet">Claude 4 Sonnet</option>
|
||||
<option value="Claude 4 Opus">Claude 4 Opus</option>
|
||||
<option value="Gemini 2.5 Pro">Gemini 2.5 Pro</option>
|
||||
<option value="DeepSeek R1">DeepSeek R1</option>
|
||||
<option value="custom">[Custom]</option>
|
||||
</select>
|
||||
<input type="text" id="designer-custom-model"
|
||||
class="w-full p-2 bg-dynamic-background border border-dynamic-border rounded text-xs focus-ring hidden touch-target"
|
||||
placeholder="Custom model">
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">Mode</label>
|
||||
<select id="designer-mode-select" class="w-full p-2 bg-dynamic-background border border-dynamic-border rounded text-xs focus-ring touch-target">
|
||||
<option value="chat">Chat</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="cloud">Cloud</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center space-x-1.5 touch-target">
|
||||
<input type="checkbox" id="designer-thinking" class="rounded border-dynamic-border w-3.5 h-3.5">
|
||||
<span class="text-xs text-dynamic-muted-foreground">Thinking</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-1.5 touch-target">
|
||||
<input type="checkbox" id="designer-max" class="rounded border-dynamic-border w-3.5 h-3.5">
|
||||
<span class="text-xs text-dynamic-muted-foreground">MAX mode</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- APPEARANCE SECTION -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs font-semibold text-dynamic-foreground uppercase tracking-wider border-b border-dynamic-border pb-1">Appearance</h3>
|
||||
|
||||
<!-- Theme Mode -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">Theme</label>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button type="button" id="theme-light" class="theme-mode-btn px-2 py-1.5 text-xs font-medium rounded border border-dynamic-border bg-dynamic-background hover:bg-dynamic-muted transition-colors text-center touch-target">
|
||||
Light
|
||||
</button>
|
||||
<button type="button" id="theme-dark" class="theme-mode-btn px-2 py-1.5 text-xs font-medium rounded border border-dynamic-border bg-dynamic-background hover:bg-dynamic-muted transition-colors text-center touch-target">
|
||||
Dark
|
||||
</button>
|
||||
<button type="button" id="theme-auto" class="theme-mode-btn px-2 py-1.5 text-xs font-medium rounded border border-dynamic-border bg-dynamic-primary text-white transition-colors text-center touch-target">
|
||||
Auto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color Presets -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">Colors</label>
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
<button type="button" class="color-preset w-full h-6 rounded border border-dynamic-border hover:border-dynamic-primary transition-colors touch-target"
|
||||
data-light="#6b7280" data-dark="#e5e7eb"
|
||||
style="background: linear-gradient(135deg, #6b7280 50%, #e5e7eb 50%)"
|
||||
title="Minimal"></button>
|
||||
<button type="button" class="color-preset w-full h-6 rounded border border-dynamic-border hover:border-dynamic-primary transition-colors touch-target"
|
||||
data-light="#1f2937" data-dark="#9ca3af"
|
||||
style="background: linear-gradient(135deg, #1f2937 50%, #9ca3af 50%)"
|
||||
title="Dark Gray"></button>
|
||||
<button type="button" class="color-preset w-full h-6 rounded border border-dynamic-border hover:border-dynamic-primary transition-colors touch-target"
|
||||
data-light="#64748b" data-dark="#94a3b8"
|
||||
style="background: linear-gradient(135deg, #64748b 50%, #94a3b8 50%)"
|
||||
title="Dimmed"></button>
|
||||
<button type="button" class="color-preset w-full h-6 rounded border border-dynamic-border hover:border-dynamic-primary transition-colors touch-target"
|
||||
data-light="#3b82f6" data-dark="#60a5fa"
|
||||
style="background: linear-gradient(135deg, #3b82f6 50%, #60a5fa 50%)"
|
||||
title="Blue"></button>
|
||||
<button type="button" class="color-preset w-full h-6 rounded border border-dynamic-border hover:border-dynamic-primary transition-colors touch-target"
|
||||
data-light="#10b981" data-dark="#34d399"
|
||||
style="background: linear-gradient(135deg, #10b981 50%, #34d399 50%)"
|
||||
title="Green"></button>
|
||||
<button type="button" class="color-preset w-full h-6 rounded border border-dynamic-border hover:border-dynamic-primary transition-colors touch-target"
|
||||
data-light="#8b5cf6" data-dark="#a78bfa"
|
||||
style="background: linear-gradient(135deg, #8b5cf6 50%, #a78bfa 50%)"
|
||||
title="Purple"></button>
|
||||
<button type="button" class="color-preset w-full h-6 rounded border border-dynamic-border hover:border-dynamic-primary transition-colors touch-target"
|
||||
data-light="#f97316" data-dark="#fb923c"
|
||||
style="background: linear-gradient(135deg, #f97316 50%, #fb923c 50%)"
|
||||
title="Orange"></button>
|
||||
<button type="button" class="color-preset w-full h-6 rounded border border-dynamic-border hover:border-dynamic-primary transition-colors touch-target"
|
||||
data-light="#ec4899" data-dark="#f472b6"
|
||||
style="background: linear-gradient(135deg, #ec4899 50%, #f472b6 50%)"
|
||||
title="Pink"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Colors -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] text-dynamic-muted-foreground">Light</label>
|
||||
<div class="flex gap-1">
|
||||
<input type="color" id="designer-light-color" value="#3b82f6" class="w-6 h-6 rounded border border-dynamic-border cursor-pointer touch-target">
|
||||
<input type="text" id="designer-light-color-text" value="#3b82f6" class="flex-1 px-1 py-1 bg-dynamic-background border border-dynamic-border rounded text-[10px] focus-ring font-mono touch-target" pattern="^#[0-9A-Fa-f]{6}$">
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] text-dynamic-muted-foreground">Dark</label>
|
||||
<div class="flex gap-1">
|
||||
<input type="color" id="designer-dark-color" value="#60a5fa" class="w-6 h-6 rounded border border-dynamic-border cursor-pointer touch-target">
|
||||
<input type="text" id="designer-dark-color-text" value="#60a5fa" class="flex-1 px-1 py-1 bg-dynamic-background border border-dynamic-border rounded text-[10px] focus-ring font-mono touch-target" pattern="^#[0-9A-Fa-f]{6}$">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Height -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">
|
||||
Height: <span id="height-value" class="text-dynamic-foreground">400</span>px
|
||||
</label>
|
||||
<input type="range" id="designer-height"
|
||||
class="w-full h-1.5 bg-dynamic-muted rounded-lg appearance-none cursor-pointer slider"
|
||||
min="200" max="800" value="400" step="50">
|
||||
<div class="flex justify-between text-[10px] text-dynamic-muted-foreground opacity-60">
|
||||
<span>200px</span>
|
||||
<span>800px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FEATURES SECTION -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs font-semibold text-dynamic-foreground uppercase tracking-wider border-b border-dynamic-border pb-1">Features</h3>
|
||||
|
||||
<!-- File Tree -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">File Tree</label>
|
||||
<label class="flex items-center space-x-1 text-[10px]">
|
||||
<input type="checkbox" id="designer-show-filetree" class="rounded border-dynamic-border w-3 h-3" checked>
|
||||
<span class="text-dynamic-muted-foreground">Show</span>
|
||||
</label>
|
||||
</div>
|
||||
<textarea id="designer-filetree"
|
||||
class="w-full p-2 bg-dynamic-background border border-dynamic-border rounded text-[10px] resize-none focus-ring custom-scrollbar touch-target font-mono"
|
||||
rows="4"
|
||||
placeholder="index.html styles/main.css scripts/app.js components/header.vue"></textarea>
|
||||
<p class="text-[9px] text-dynamic-muted-foreground opacity-60">
|
||||
One per line. Use / for folders. Add * to highlight.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Diff View -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-dynamic-muted-foreground">Diff View</label>
|
||||
<label class="flex items-center space-x-1 text-[10px]">
|
||||
<input type="checkbox" id="designer-show-diff" class="rounded border-dynamic-border w-3 h-3">
|
||||
<span class="text-dynamic-muted-foreground">Show</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="diff-fields" class="space-y-2 hidden">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-[10px] text-dynamic-muted-foreground">Filename</label>
|
||||
<input type="text" id="designer-diff-filename"
|
||||
class="w-full p-1.5 bg-dynamic-background border border-dynamic-border rounded text-[10px] focus-ring touch-target font-mono"
|
||||
placeholder="file.tsx">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] text-dynamic-muted-foreground">Flash</label>
|
||||
<select id="designer-flash-button" class="w-full p-1.5 bg-dynamic-background border border-dynamic-border rounded text-[10px] focus-ring touch-target">
|
||||
<option value="none">None</option>
|
||||
<option value="accept">Accept</option>
|
||||
<option value="reject">Reject</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] text-dynamic-muted-foreground">Old Text</label>
|
||||
<textarea id="designer-diff-old"
|
||||
class="w-full p-2 bg-dynamic-background border border-dynamic-border rounded text-[10px] resize-none focus-ring custom-scrollbar touch-target font-mono"
|
||||
rows="3"
|
||||
placeholder="// Original code..."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] text-dynamic-muted-foreground">New Text</label>
|
||||
<textarea id="designer-diff-new"
|
||||
class="w-full p-2 bg-dynamic-background border border-dynamic-border rounded text-[10px] resize-none focus-ring custom-scrollbar touch-target font-mono"
|
||||
rows="3"
|
||||
placeholder="// Updated code..."></textarea>
|
||||
</div>
|
||||
<p class="text-[9px] text-dynamic-muted-foreground opacity-50">
|
||||
Keep short for URL sharing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generate Buttons - Fixed at bottom -->
|
||||
<div class="p-3 lg:p-4 space-y-2 border-t border-dynamic-border bg-dynamic-muted">
|
||||
<button id="generate-embed" class="w-full bg-dynamic-primary text-white p-2.5 rounded text-xs font-medium hover:opacity-90 transition-opacity touch-target">
|
||||
Generate Embed Code
|
||||
</button>
|
||||
<button id="reset-settings" class="text-[10px] text-dynamic-muted-foreground hover:text-dynamic-foreground transition-colors opacity-50 hover:opacity-100 touch-target">
|
||||
Reset settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Preview -->
|
||||
<div class="flex-none lg:flex-1 flex items-center justify-center px-4 pb-4 pt-0 lg:p-8 bg-dynamic-muted/20 order-1 lg:order-2 min-h-[300px] lg:min-h-0">
|
||||
<div class="w-full max-w-3xl">
|
||||
<h3 class="text-center text-sm font-medium text-dynamic-muted-foreground mb-4 hidden lg:block">Preview of the embed. You can change the settings below.</h3>
|
||||
<div id="preview-wrapper" class="bg-dynamic-background border border-dynamic-border rounded-xl overflow-hidden shadow-lg h-[250px] sm:h-[300px] lg:h-[400px] mt-4 lg:mt-0">
|
||||
<div class="h-full" id="preview-container">
|
||||
<!-- Preview iframe will be injected here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 space-y-1">
|
||||
<label class="text-[10px] text-dynamic-muted-foreground opacity-60 dark:opacity-40">Click to copy iframe code:</label>
|
||||
<div id="iframe-snippet" class="px-2 py-1 bg-dynamic-muted/30 border border-dynamic-border/50 rounded text-[10px] text-dynamic-muted-foreground font-mono cursor-pointer hover:bg-dynamic-muted/50 hover:text-dynamic-foreground transition-all overflow-hidden touch-target">
|
||||
<iframe src="..."></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embed Code Modal -->
|
||||
<div id="embed-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-dynamic-background rounded-xl p-4 lg:p-6 max-w-2xl w-full max-h-[90vh] lg:max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-dynamic-foreground">Embed Code</h3>
|
||||
<button id="close-modal" class="text-dynamic-muted-foreground hover:text-dynamic-foreground touch-target p-1">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-dynamic-muted-foreground">iframe Embed Code</label>
|
||||
<textarea id="embed-code" class="w-full p-3 bg-dynamic-muted border border-dynamic-border rounded-lg text-sm font-mono mt-2 resize-none" rows="4" readonly></textarea>
|
||||
<button id="copy-embed-code" class="mt-2 px-4 py-3 lg:py-2 bg-dynamic-primary text-white rounded-lg text-sm hover:opacity-90 touch-target">Copy Code</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-dynamic-muted-foreground">Share URL</label>
|
||||
<textarea id="share-url" class="w-full p-3 bg-dynamic-muted border border-dynamic-border rounded-lg text-sm font-mono mt-2 resize-none" rows="2" readonly></textarea>
|
||||
<button id="copy-share-url" class="mt-2 px-4 py-3 lg:py-2 bg-dynamic-muted text-dynamic-foreground rounded-lg text-sm hover:bg-opacity-80 touch-target">Copy URL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification -->
|
||||
<div id="notification" class="fixed top-4 right-4 bg-dynamic-accent text-white px-4 py-2 rounded-lg font-medium opacity-0 transition-opacity z-50 pointer-events-none"></div>
|
||||
|
||||
<script src="{{ '/embed-script.js' | relative_url }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
26
docker-compose.dev.yml
Normal file
26
docker-compose.dev.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
version: '3.8'
|
||||
|
||||
# Development docker-compose - only runs the database
|
||||
# The Next.js app runs locally with npm run dev
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: prompts-chat-db-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: prompts_chat
|
||||
volumes:
|
||||
- postgres_data_dev:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data_dev:
|
||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: prompts-chat-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: prompts_chat
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Next.js Application
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: prompts-chat-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/prompts_chat?schema=public
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-change-me-in-production-use-openssl-rand-base64-32}
|
||||
# Optional: OAuth providers
|
||||
# GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||
# GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||
# AZURE_AD_CLIENT_ID: ${AZURE_AD_CLIENT_ID}
|
||||
# AZURE_AD_CLIENT_SECRET: ${AZURE_AD_CLIENT_SECRET}
|
||||
# AZURE_AD_TENANT_ID: ${AZURE_AD_TENANT_ID}
|
||||
# Optional: S3 storage
|
||||
# S3_BUCKET: ${S3_BUCKET}
|
||||
# S3_REGION: ${S3_REGION}
|
||||
# S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
||||
# S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
||||
# S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -1,855 +0,0 @@
|
||||
class EmbedPreview {
|
||||
constructor() {
|
||||
this.params = this.parseURLParams();
|
||||
this.config = this.getInitialConfig();
|
||||
this.selectedFiles = new Set(); // Track selected files
|
||||
this.init();
|
||||
}
|
||||
|
||||
parseURLParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const params = {};
|
||||
for (const [key, value] of urlParams.entries()) {
|
||||
params[key] = decodeURIComponent(value);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
getInitialConfig() {
|
||||
return {
|
||||
prompt: this.params.prompt || '',
|
||||
context: this.params.context ? this.params.context.split(',').map(c => c.trim()) : [],
|
||||
model: this.params.model || 'gpt-4o',
|
||||
mode: this.params.agentMode || 'chat',
|
||||
thinking: this.params.thinking === 'true',
|
||||
max: this.params.max === 'true',
|
||||
lightColor: this.params.lightColor || '#3b82f6',
|
||||
darkColor: this.params.darkColor || '#60a5fa',
|
||||
themeMode: this.params.themeMode || 'auto',
|
||||
filetree: this.params.filetree ? decodeURIComponent(this.params.filetree).split('\n').filter(f => f.trim()) : [],
|
||||
showDiff: this.params.showDiff === 'true',
|
||||
diffFilename: this.params.diffFilename || '',
|
||||
diffOldText: this.params.diffOldText ? decodeURIComponent(this.params.diffOldText) : '',
|
||||
diffNewText: this.params.diffNewText ? decodeURIComponent(this.params.diffNewText) : '',
|
||||
flashButton: this.params.flashButton || 'none'
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupColors();
|
||||
this.setupElements();
|
||||
this.render();
|
||||
this.setupResizeListener();
|
||||
}
|
||||
|
||||
setupColors() {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Determine if we should use dark mode
|
||||
let isDarkMode;
|
||||
if (this.config.themeMode === 'light') {
|
||||
isDarkMode = false;
|
||||
} else if (this.config.themeMode === 'dark') {
|
||||
isDarkMode = true;
|
||||
} else {
|
||||
// Auto mode - use system preference
|
||||
isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
const baseColor = isDarkMode ? this.config.darkColor : this.config.lightColor;
|
||||
|
||||
// Convert hex to RGB
|
||||
const hexToRgb = (hex) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
};
|
||||
|
||||
// Convert RGB to HSL
|
||||
const rgbToHsl = (r, g, b) => {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0;
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
};
|
||||
|
||||
// Convert HSL to RGB
|
||||
const hslToRgb = (h, s, l) => {
|
||||
h /= 360;
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
|
||||
let r, g, b;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||
if (t < 1/2) return q;
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1/3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255)
|
||||
};
|
||||
};
|
||||
|
||||
const rgb = hexToRgb(baseColor);
|
||||
if (rgb) {
|
||||
// Get HSL values for color manipulation
|
||||
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
// Set primary color
|
||||
root.style.setProperty('--primary', `${rgb.r} ${rgb.g} ${rgb.b}`);
|
||||
|
||||
// Generate color scheme based on primary color
|
||||
if (isDarkMode) {
|
||||
// Dark mode: darker backgrounds, lighter text
|
||||
const bgHsl = { ...hsl, s: Math.min(hsl.s * 0.15, 20), l: 8 };
|
||||
const bg = hslToRgb(bgHsl.h, bgHsl.s, bgHsl.l);
|
||||
|
||||
const mutedHsl = { ...hsl, s: Math.min(hsl.s * 0.2, 25), l: 15 };
|
||||
const muted = hslToRgb(mutedHsl.h, mutedHsl.s, mutedHsl.l);
|
||||
|
||||
const borderHsl = { ...hsl, s: Math.min(hsl.s * 0.25, 30), l: 20 };
|
||||
const border = hslToRgb(borderHsl.h, borderHsl.s, borderHsl.l);
|
||||
|
||||
root.style.setProperty('--background', `${bg.r} ${bg.g} ${bg.b}`);
|
||||
root.style.setProperty('--foreground', '248 250 252');
|
||||
root.style.setProperty('--muted', `${muted.r} ${muted.g} ${muted.b}`);
|
||||
root.style.setProperty('--muted-foreground', '148 163 184');
|
||||
root.style.setProperty('--border', `${border.r} ${border.g} ${border.b}`);
|
||||
} else {
|
||||
// Light mode: lighter backgrounds, darker text
|
||||
const bgHsl = { ...hsl, s: Math.min(hsl.s * 0.05, 5), l: 99 };
|
||||
const bg = hslToRgb(bgHsl.h, bgHsl.s, bgHsl.l);
|
||||
|
||||
const mutedHsl = { ...hsl, s: Math.min(hsl.s * 0.1, 10), l: 97 };
|
||||
const muted = hslToRgb(mutedHsl.h, mutedHsl.s, mutedHsl.l);
|
||||
|
||||
const borderHsl = { ...hsl, s: Math.min(hsl.s * 0.15, 15), l: 92 };
|
||||
const border = hslToRgb(borderHsl.h, borderHsl.s, borderHsl.l);
|
||||
|
||||
root.style.setProperty('--background', `${bg.r} ${bg.g} ${bg.b}`);
|
||||
root.style.setProperty('--foreground', '15 23 42');
|
||||
root.style.setProperty('--muted', `${muted.r} ${muted.g} ${muted.b}`);
|
||||
root.style.setProperty('--muted-foreground', '100 116 139');
|
||||
root.style.setProperty('--border', `${border.r} ${border.g} ${border.b}`);
|
||||
}
|
||||
|
||||
// Set accent (slightly different from primary)
|
||||
const accentHsl = { ...hsl, l: Math.min(hsl.l + 10, 90) };
|
||||
const accent = hslToRgb(accentHsl.h, accentHsl.s, accentHsl.l);
|
||||
root.style.setProperty('--accent', `${accent.r} ${accent.g} ${accent.b}`);
|
||||
}
|
||||
|
||||
this.isDarkMode = isDarkMode;
|
||||
}
|
||||
|
||||
setupElements() {
|
||||
const copyButton = document.getElementById('copy-button');
|
||||
if (copyButton) {
|
||||
copyButton.addEventListener('click', () => this.handleCopy());
|
||||
}
|
||||
|
||||
const editButton = document.getElementById('edit-button');
|
||||
if (editButton) {
|
||||
editButton.addEventListener('click', () => this.handleEdit());
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.renderDiffView();
|
||||
this.renderContextPills();
|
||||
this.renderPromptText();
|
||||
this.renderSettingsPills();
|
||||
this.renderFileTree();
|
||||
}
|
||||
|
||||
renderContextPills() {
|
||||
const container = document.getElementById('context-pills');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// Render initial context from config
|
||||
this.config.context.forEach(context => {
|
||||
const pill = this.createContextPill(context, false);
|
||||
container.appendChild(pill);
|
||||
});
|
||||
|
||||
// Render selected files and folders
|
||||
this.selectedFiles.forEach(filePath => {
|
||||
const fileName = this.getFileName(filePath);
|
||||
const pill = this.createContextPill(fileName, true, filePath);
|
||||
pill.title = filePath; // Show full path on hover
|
||||
container.appendChild(pill);
|
||||
});
|
||||
}
|
||||
|
||||
createContextPill(context, isRemovable, fullPath = null) {
|
||||
const pill = document.createElement('div');
|
||||
pill.className = 'px-2 py-0.5 rounded-lg text-[0.65rem] font-medium animate-slide-in flex items-center gap-1 flex-shrink-0 whitespace-nowrap';
|
||||
|
||||
let icon = '';
|
||||
let content = '';
|
||||
|
||||
// Use dynamic color classes for all pills
|
||||
if (this.isDarkMode) {
|
||||
pill.className += ' bg-dynamic-primary/10 text-dynamic-foreground border border-dynamic-primary/30';
|
||||
} else {
|
||||
pill.className += ' bg-dynamic-primary/0 text-dynamic-foreground border border-dynamic-primary/20';
|
||||
}
|
||||
|
||||
if (context.startsWith('@')) {
|
||||
// @mentions - just show the text
|
||||
content = '<span>' + context + '</span>';
|
||||
} else if (context.startsWith('http://') || context.startsWith('https://')) {
|
||||
// Web URLs show world icon
|
||||
icon = '<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd"/></svg>';
|
||||
content = icon + '<span>' + context + '</span>';
|
||||
} else if (context.startsWith('#')) {
|
||||
// Any hashtag context shows image icon
|
||||
icon = '<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg>';
|
||||
// Remove hash from display
|
||||
content = icon + '<span>' + context.substring(1) + '</span>';
|
||||
} else if (context.endsWith('/')) {
|
||||
// Folder context (ends with /)
|
||||
icon = '<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>';
|
||||
// Keep trailing slash for display
|
||||
content = icon + '<span>' + context + '</span>';
|
||||
} else if (context.includes('.') || isRemovable) {
|
||||
// File context (contains a dot) or removable file from sidebar
|
||||
icon = '<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V7.414A2 2 0 0017.414 6L14 2.586A2 2 0 0012.586 2H4zm2 4a1 1 0 011-1h4a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7zm-1 5a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
|
||||
content = icon + '<span>' + context + '</span>';
|
||||
} else {
|
||||
// Generic context
|
||||
content = '<span>' + context + '</span>';
|
||||
}
|
||||
|
||||
pill.innerHTML = content;
|
||||
|
||||
// Add remove button for removable pills
|
||||
if (isRemovable && fullPath) {
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-0.5 text-dynamic-muted-foreground hover:text-dynamic-foreground transition-colors';
|
||||
removeBtn.innerHTML = '<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.removeFileFromContext(fullPath);
|
||||
});
|
||||
pill.appendChild(removeBtn);
|
||||
}
|
||||
|
||||
return pill;
|
||||
}
|
||||
|
||||
renderPromptText() {
|
||||
const promptText = document.getElementById('prompt-text');
|
||||
const placeholder = document.getElementById('prompt-placeholder');
|
||||
|
||||
if (!promptText || !placeholder) return;
|
||||
|
||||
if (this.config.prompt) {
|
||||
promptText.innerHTML = this.highlightMentions(this.config.prompt);
|
||||
placeholder.style.display = 'none';
|
||||
} else {
|
||||
promptText.innerHTML = '';
|
||||
placeholder.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
renderSettingsPills() {
|
||||
const container = document.getElementById('settings-pills');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// Mode pill (first)
|
||||
const modePill = this.createSettingPill(this.capitalizeFirst(this.config.mode), 'mode');
|
||||
container.appendChild(modePill);
|
||||
|
||||
// Model pill (second)
|
||||
const modelPill = this.createSettingPill(this.config.model, 'model');
|
||||
container.appendChild(modelPill);
|
||||
|
||||
// Thinking pill (if enabled)
|
||||
if (this.config.thinking) {
|
||||
const thinkingPill = this.createSettingPill('Thinking', 'thinking');
|
||||
container.appendChild(thinkingPill);
|
||||
}
|
||||
|
||||
// MAX pill (if enabled)
|
||||
if (this.config.max) {
|
||||
const maxPill = this.createSettingPill('MAX', 'max');
|
||||
container.appendChild(maxPill);
|
||||
}
|
||||
}
|
||||
|
||||
createSettingPill(text, type) {
|
||||
const pill = document.createElement('div');
|
||||
pill.className = 'rounded-full text-[10px] font-medium flex items-center gap-1';
|
||||
|
||||
let icon = '';
|
||||
|
||||
// Use different styling based on pill type
|
||||
if (type === 'mode') {
|
||||
// Mode pill keeps the background with reduced padding
|
||||
if (this.isDarkMode) {
|
||||
pill.className += ' px-2 py-1 bg-dynamic-primary/20 text-dynamic-foreground border border-dynamic-primary/30';
|
||||
} else {
|
||||
pill.className += ' px-2 py-1 bg-dynamic-primary/10 text-dynamic-foreground border border-dynamic-primary/20';
|
||||
}
|
||||
} else {
|
||||
// Model, thinking, and max pills only have text color
|
||||
pill.className += ' pl-0.5 text-dynamic-primary';
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'model':
|
||||
pill.innerHTML = '<span>' + text + '</span>';
|
||||
break;
|
||||
case 'mode':
|
||||
// Add icon based on mode type
|
||||
const modeType = text.toLowerCase();
|
||||
switch (modeType) {
|
||||
case 'agent':
|
||||
icon = '<svg class="w-2.5 h-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18.18 8.04c-.78-.84-1.92-1.54-3.18-1.54-1.44 0-2.7.93-3.48 2.25-.78-1.32-2.04-2.25-3.48-2.25-1.26 0-2.4.7-3.18 1.54-.87.94-1.36 2.11-1.36 3.46 0 1.35.49 2.52 1.36 3.46.78.84 1.92 1.54 3.18 1.54 1.44 0 2.7-.93 3.48-2.25.78 1.32 2.04 2.25 3.48 2.25 1.26 0 2.4-.7 3.18-1.54.87-.94 1.36-2.11 1.36-3.46 0-1.35-.49-2.52-1.36-3.46z"/></svg>';
|
||||
break;
|
||||
case 'chat':
|
||||
icon = '<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clip-rule="evenodd"/></svg>';
|
||||
break;
|
||||
case 'manual':
|
||||
icon = '<svg class="w-2.5 h-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2v20M2 12h20"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
break;
|
||||
case 'cloud':
|
||||
icon = '<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor"><path d="M5.5 16a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 16h-8z"/></svg>';
|
||||
break;
|
||||
default:
|
||||
icon = '';
|
||||
}
|
||||
|
||||
pill.innerHTML = icon + '<span>' + text + '</span>';
|
||||
break;
|
||||
case 'thinking':
|
||||
// Brain icon for thinking mode
|
||||
icon = '<svg class="w-2.5 h-2.5" viewBox="0 0 24 24" fill="currentColor"><path d="M19.864 8.465a3.505 3.505 0 0 0-3.03-4.449A3.005 3.005 0 0 0 14 2a2.98 2.98 0 0 0-2 .78A2.98 2.98 0 0 0 10 2c-1.301 0-2.41.831-2.825 2.015a3.505 3.505 0 0 0-3.039 4.45A4.028 4.028 0 0 0 2 12c0 1.075.428 2.086 1.172 2.832A4.067 4.067 0 0 0 3 16c0 1.957 1.412 3.59 3.306 3.934A3.515 3.515 0 0 0 9.5 22c.979 0 1.864-.407 2.5-1.059A3.484 3.484 0 0 0 14.5 22a3.51 3.51 0 0 0 3.19-2.06 4.006 4.006 0 0 0 3.138-5.108A4.003 4.003 0 0 0 22 12a4.028 4.028 0 0 0-2.136-3.535zM9.5 20c-.711 0-1.33-.504-1.47-1.198L7.818 18H7c-1.103 0-2-.897-2-2 0-.352.085-.682.253-.981l.456-.816-.784-.51A2.019 2.019 0 0 1 4 12c0-.977.723-1.824 1.682-1.972l1.693-.26-1.059-1.346a1.502 1.502 0 0 1 1.498-2.39L9 6.207V5a1 1 0 0 1 2 0v13.5c0 .827-.673 1.5-1.5 1.5zm9.575-6.308-.784.51.456.816c.168.3.253.63.253.982 0 1.103-.897 2-2.05 2h-.818l-.162.802A1.502 1.502 0 0 1 14.5 20c-.827 0-1.5-.673-1.5-1.5V5c0-.552.448-1 1-1s1 .448 1 1.05v1.207l1.186-.225a1.502 1.502 0 0 1 1.498 2.39l-1.059 1.347 1.693.26A2.002 2.002 0 0 1 20 12c0 .683-.346 1.315-.925 1.692z"></path></svg>';
|
||||
pill.innerHTML = icon + '<span>' + text + '</span>';
|
||||
break;
|
||||
case 'max':
|
||||
// Lightning bolt icon for MAX mode
|
||||
icon = '<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor"><path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"/></svg>';
|
||||
pill.innerHTML = icon + '<span>' + text + '</span>';
|
||||
break;
|
||||
}
|
||||
|
||||
return pill;
|
||||
}
|
||||
|
||||
setupResizeListener() {
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
this.renderDiffView();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
renderDiffView() {
|
||||
const diffView = document.getElementById('diff-view');
|
||||
const diffViewInline = document.getElementById('diff-view-inline');
|
||||
const promptText = document.getElementById('prompt-text');
|
||||
if (!diffView || !diffViewInline) return;
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const isCompact = viewportHeight < 400;
|
||||
|
||||
if (this.config.showDiff && (this.config.diffOldText || this.config.diffNewText)) {
|
||||
// Determine which view to show based on viewport height
|
||||
if (isCompact) {
|
||||
// Show floating diff view
|
||||
diffView.classList.remove('hidden');
|
||||
diffView.classList.add('diff-enter');
|
||||
diffViewInline.classList.add('hidden');
|
||||
this.setupDiffContent('diff-view');
|
||||
} else {
|
||||
// Show inline diff view
|
||||
diffViewInline.classList.remove('hidden');
|
||||
diffView.classList.add('hidden');
|
||||
this.setupDiffContent('diff-view-inline');
|
||||
// Reset margin when switching to inline view
|
||||
if (promptText) {
|
||||
promptText.style.marginTop = '0';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
diffView.classList.add('hidden');
|
||||
diffViewInline.classList.add('hidden');
|
||||
// Reset margin when diff is hidden
|
||||
if (promptText) {
|
||||
promptText.style.marginTop = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupDiffContent(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
// Count lines in old and new content
|
||||
const oldLines = this.config.diffOldText ? this.config.diffOldText.split('\n').length : 0;
|
||||
const newLines = this.config.diffNewText ? this.config.diffNewText.split('\n').length : 0;
|
||||
|
||||
// Set filename with line counts
|
||||
const filenameElement = container.querySelector('[id$="-filename"]');
|
||||
if (filenameElement) {
|
||||
const filename = this.config.diffFilename || 'untitled';
|
||||
filenameElement.innerHTML = `
|
||||
<span>${filename}</span>
|
||||
<span class="ml-2 text-[10px] font-mono">
|
||||
<span class="text-green-600 dark:text-green-400">+${newLines}</span>
|
||||
<span class="text-red-600 dark:text-red-400">-${oldLines}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Set diff content
|
||||
const oldContent = container.querySelector('[id$="-old-content"]');
|
||||
const newContent = container.querySelector('[id$="-new-content"]');
|
||||
|
||||
if (oldContent) {
|
||||
oldContent.textContent = this.config.diffOldText || '(empty)';
|
||||
// Update colors based on dark mode
|
||||
if (this.isDarkMode) {
|
||||
oldContent.style.backgroundColor = 'rgba(127, 29, 29, 0.15)';
|
||||
oldContent.style.color = '#fca5a5';
|
||||
oldContent.style.borderColor = 'rgba(127, 29, 29, 0.3)';
|
||||
} else {
|
||||
oldContent.style.backgroundColor = 'rgba(254, 226, 226, 0.7)';
|
||||
oldContent.style.color = '#b91c1c';
|
||||
oldContent.style.borderColor = 'rgba(252, 165, 165, 0.5)';
|
||||
}
|
||||
}
|
||||
if (newContent) {
|
||||
newContent.textContent = this.config.diffNewText || '(empty)';
|
||||
// Update colors based on dark mode
|
||||
if (this.isDarkMode) {
|
||||
newContent.style.backgroundColor = 'rgba(20, 83, 45, 0.15)';
|
||||
newContent.style.color = '#86efac';
|
||||
newContent.style.borderColor = 'rgba(20, 83, 45, 0.3)';
|
||||
} else {
|
||||
newContent.style.backgroundColor = 'rgba(220, 252, 231, 0.7)';
|
||||
newContent.style.color = '#15803d';
|
||||
newContent.style.borderColor = 'rgba(134, 239, 172, 0.5)';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply flash animation to buttons if configured
|
||||
const acceptButton = container.querySelector('[id$="-accept-diff"]');
|
||||
const rejectButton = container.querySelector('[id$="-reject-diff"]');
|
||||
|
||||
if (this.config.flashButton === 'accept' && acceptButton) {
|
||||
acceptButton.classList.add('flash-animation');
|
||||
// Remove from reject button if it exists
|
||||
if (rejectButton) rejectButton.classList.remove('flash-animation');
|
||||
} else if (this.config.flashButton === 'reject' && rejectButton) {
|
||||
rejectButton.classList.add('flash-animation');
|
||||
// Remove from accept button if it exists
|
||||
if (acceptButton) acceptButton.classList.remove('flash-animation');
|
||||
} else {
|
||||
// Remove animation from both if 'none'
|
||||
if (acceptButton) acceptButton.classList.remove('flash-animation');
|
||||
if (rejectButton) rejectButton.classList.remove('flash-animation');
|
||||
}
|
||||
|
||||
// Setup toggle button for floating view only
|
||||
if (containerId === 'diff-view') {
|
||||
const toggleButton = container.querySelector('#toggle-diff');
|
||||
const diffContent = container.querySelector('#diff-content');
|
||||
const promptText = document.getElementById('prompt-text');
|
||||
|
||||
if (toggleButton && diffContent && promptText) {
|
||||
// Remove existing listeners
|
||||
const newToggleButton = toggleButton.cloneNode(true);
|
||||
toggleButton.parentNode.replaceChild(newToggleButton, toggleButton);
|
||||
|
||||
// Set initial state - collapsed when compact
|
||||
let isExpanded = false;
|
||||
diffContent.style.maxHeight = '0';
|
||||
diffContent.style.overflow = 'hidden';
|
||||
newToggleButton.querySelector('svg').style.transform = 'rotate(-90deg)';
|
||||
|
||||
// Function to update prompt margin based on diff view height
|
||||
const updatePromptMargin = () => {
|
||||
const diffHeight = container.offsetHeight;
|
||||
promptText.parentElement.style.paddingTop = `${diffHeight + 16}px`; // 16px for some breathing room
|
||||
};
|
||||
|
||||
// Set initial margin
|
||||
setTimeout(updatePromptMargin, 0);
|
||||
|
||||
newToggleButton.addEventListener('click', () => {
|
||||
isExpanded = !isExpanded;
|
||||
|
||||
if (isExpanded) {
|
||||
diffContent.style.maxHeight = diffContent.scrollHeight + 'px';
|
||||
newToggleButton.querySelector('svg').style.transform = 'rotate(0deg)';
|
||||
// Update margin after animation
|
||||
setTimeout(updatePromptMargin, 300);
|
||||
} else {
|
||||
diffContent.style.maxHeight = '0';
|
||||
diffContent.style.overflow = 'hidden';
|
||||
newToggleButton.querySelector('svg').style.transform = 'rotate(-90deg)';
|
||||
// Update margin after animation
|
||||
setTimeout(updatePromptMargin, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlightMentions(text) {
|
||||
return text.replaceAll(/@(https?:\/\/[^\s]+.*?|\w+\.\w+|\w+)/g, '<span class="mention">@$1</span>');
|
||||
}
|
||||
|
||||
capitalizeFirst(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
async handleCopy() {
|
||||
if (!this.config.prompt) {
|
||||
this.showNotification('No prompt to copy');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.copyToClipboard(this.config.prompt);
|
||||
this.showNotification('Prompt copied to clipboard!');
|
||||
}
|
||||
|
||||
handleEdit() {
|
||||
// Get current query string
|
||||
const queryString = window.location.search;
|
||||
|
||||
// Get current URL path parts
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
|
||||
// Find index of 'embed-preview' and replace with 'embed'
|
||||
const embedPreviewIndex = pathParts.findIndex(part => part === 'embed-preview');
|
||||
if (embedPreviewIndex !== -1) {
|
||||
pathParts[embedPreviewIndex] = 'embed';
|
||||
} else {
|
||||
// Fallback: just append /embed/ if embed-preview not found
|
||||
pathParts.push('embed');
|
||||
}
|
||||
|
||||
// Construct new URL
|
||||
const newPath = pathParts.join('/');
|
||||
const newUrl = window.location.origin + newPath + queryString;
|
||||
|
||||
// Open in new tab
|
||||
window.open(newUrl, '_blank');
|
||||
}
|
||||
|
||||
async copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// Fallback
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
textArea.remove();
|
||||
}
|
||||
}
|
||||
|
||||
showNotification(message) {
|
||||
const notification = document.getElementById('notification');
|
||||
if (!notification) return;
|
||||
|
||||
notification.textContent = message;
|
||||
notification.classList.remove('opacity-0');
|
||||
notification.classList.add('opacity-100');
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('opacity-100');
|
||||
notification.classList.add('opacity-0');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
addFileToContext(filePath) {
|
||||
if (!this.selectedFiles.has(filePath)) {
|
||||
this.selectedFiles.add(filePath);
|
||||
this.renderContextPills();
|
||||
this.renderFileTree(); // Re-render to show selection
|
||||
}
|
||||
}
|
||||
|
||||
getFileName(filePath) {
|
||||
// Extract just the filename or folder name from the path
|
||||
const cleanPath = filePath.endsWith('/') ? filePath.slice(0, -1) : filePath;
|
||||
const parts = cleanPath.split('/');
|
||||
const name = parts[parts.length - 1];
|
||||
// Add trailing slash back for folders to maintain context
|
||||
return filePath.endsWith('/') ? name + '/' : name;
|
||||
}
|
||||
|
||||
removeFileFromContext(filePath) {
|
||||
if (this.selectedFiles.has(filePath)) {
|
||||
this.selectedFiles.delete(filePath);
|
||||
this.renderContextPills();
|
||||
this.renderFileTree(); // Re-render to update selection
|
||||
}
|
||||
}
|
||||
|
||||
getFullPath(node, currentPath = '') {
|
||||
// Helper to get the full path from a node
|
||||
return currentPath ? `${currentPath}/${node.name}` : node.name;
|
||||
}
|
||||
|
||||
buildFileTree(paths) {
|
||||
const tree = {};
|
||||
|
||||
paths.forEach(path => {
|
||||
// Check if the path ends with an asterisk
|
||||
const isHighlighted = path.endsWith('*');
|
||||
// Remove asterisk if present
|
||||
const cleanPath = isHighlighted ? path.slice(0, -1) : path;
|
||||
|
||||
// Check if it's a folder (ends with /)
|
||||
const isFolder = cleanPath.endsWith('/');
|
||||
|
||||
// Remove trailing slash for processing
|
||||
const processPath = isFolder ? cleanPath.slice(0, -1) : cleanPath;
|
||||
|
||||
const parts = processPath.split('/').filter(p => p !== ''); // Filter out empty strings
|
||||
let current = tree;
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
if (!current[part]) {
|
||||
const isLastPart = index === parts.length - 1;
|
||||
current[part] = {
|
||||
name: part,
|
||||
isFile: isLastPart && !isFolder,
|
||||
isHighlighted: isLastPart && isHighlighted,
|
||||
children: {}
|
||||
};
|
||||
}
|
||||
if (index < parts.length - 1 || (index === parts.length - 1 && !current[part].isFile)) {
|
||||
current = current[part].children;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
renderFileTree() {
|
||||
const sidebar = document.getElementById('file-sidebar');
|
||||
const treeContainer = document.getElementById('file-tree');
|
||||
|
||||
if (!sidebar || !treeContainer) return;
|
||||
|
||||
// Show/hide sidebar based on whether there are files
|
||||
if (this.config.filetree && this.config.filetree.length > 0) {
|
||||
sidebar.classList.remove('hidden');
|
||||
|
||||
// Build tree structure
|
||||
const tree = this.buildFileTree(this.config.filetree);
|
||||
|
||||
// Clear existing content
|
||||
treeContainer.innerHTML = '';
|
||||
|
||||
// Render tree
|
||||
this.renderTreeNode(tree, treeContainer, 0, '');
|
||||
} else {
|
||||
sidebar.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
renderTreeNode(node, container, level, parentPath) {
|
||||
const sortedKeys = Object.keys(node).sort((a, b) => {
|
||||
// Folders first, then files
|
||||
const aIsFile = node[a].isFile;
|
||||
const bIsFile = node[b].isFile;
|
||||
if (aIsFile !== bIsFile) return aIsFile ? 1 : -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
sortedKeys.forEach(key => {
|
||||
const item = node[key];
|
||||
const fullPath = parentPath ? `${parentPath}/${item.name}` : item.name;
|
||||
const itemElement = document.createElement('div');
|
||||
|
||||
// Check if file or folder is selected
|
||||
const contextPath = item.isFile ? fullPath : fullPath + '/';
|
||||
const isSelected = this.selectedFiles.has(contextPath);
|
||||
|
||||
// Add highlighting class if the file is marked or selected
|
||||
if (item.isHighlighted) {
|
||||
itemElement.className = 'flex items-center gap-1 py-0.5 px-1.5 bg-dynamic-primary/20 rounded text-xs text-dynamic-foreground font-medium transition-all';
|
||||
} else if (isSelected) {
|
||||
itemElement.className = 'flex items-center gap-1 py-0.5 px-1.5 bg-dynamic-primary/20 rounded cursor-pointer text-xs text-dynamic-foreground font-medium transition-all hover:bg-dynamic-primary/30';
|
||||
} else {
|
||||
itemElement.className = 'flex items-center gap-1 py-0.5 px-1.5 hover:bg-dynamic-primary/10 rounded cursor-pointer text-xs text-dynamic-foreground/80 hover:text-dynamic-foreground transition-colors';
|
||||
}
|
||||
|
||||
itemElement.style.paddingLeft = `${level * 12 + 6}px`;
|
||||
|
||||
// Add click handler for files and folders (but not starred items)
|
||||
if (!item.isHighlighted) {
|
||||
itemElement.addEventListener('click', () => {
|
||||
const contextPath = item.isFile ? fullPath : fullPath + '/';
|
||||
if (isSelected) {
|
||||
this.removeFileFromContext(contextPath);
|
||||
} else {
|
||||
this.addFileToContext(contextPath);
|
||||
}
|
||||
});
|
||||
itemElement.title = isSelected ? 'Click to remove from context' : 'Click to add to context';
|
||||
itemElement.style.cursor = 'pointer';
|
||||
} else {
|
||||
itemElement.style.cursor = 'default';
|
||||
itemElement.title = 'Starred items cannot be selected';
|
||||
}
|
||||
|
||||
// Add icon
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'flex-shrink-0';
|
||||
|
||||
if (item.isFile) {
|
||||
// File icon with different colors based on extension
|
||||
const ext = key.split('.').pop().toLowerCase();
|
||||
let iconColor = 'text-dynamic-muted-foreground';
|
||||
|
||||
// If highlighted or selected, use primary color for icon
|
||||
if (item.isHighlighted || isSelected) {
|
||||
iconColor = 'text-dynamic-primary';
|
||||
} else {
|
||||
// Color code common file types
|
||||
if (['js', 'jsx', 'ts', 'tsx'].includes(ext)) {
|
||||
iconColor = 'text-yellow-500';
|
||||
} else if (['css', 'scss', 'sass', 'less'].includes(ext)) {
|
||||
iconColor = 'text-blue-500';
|
||||
} else if (['html', 'htm'].includes(ext)) {
|
||||
iconColor = 'text-orange-500';
|
||||
} else if (['vue', 'svelte'].includes(ext)) {
|
||||
iconColor = 'text-green-500';
|
||||
} else if (['json', 'xml', 'yaml', 'yml'].includes(ext)) {
|
||||
iconColor = 'text-purple-500';
|
||||
} else if (['md', 'mdx'].includes(ext)) {
|
||||
iconColor = 'text-gray-500';
|
||||
} else if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) {
|
||||
iconColor = 'text-pink-500';
|
||||
}
|
||||
}
|
||||
|
||||
icon.innerHTML = `<svg class="w-3 h-3 ${iconColor}" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V7.414A2 2 0 0017.414 6L14 2.586A2 2 0 0012.586 2H4zm2 4a1 1 0 011-1h4a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7zm-1 5a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>`;
|
||||
} else {
|
||||
// Folder icon
|
||||
icon.innerHTML = '<svg class="w-3 h-3 text-dynamic-primary" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>';
|
||||
}
|
||||
|
||||
// Add name
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate flex-1';
|
||||
nameSpan.textContent = item.name;
|
||||
|
||||
itemElement.appendChild(icon);
|
||||
itemElement.appendChild(nameSpan);
|
||||
|
||||
// Add indicators for highlighted or selected items
|
||||
if (item.isHighlighted || isSelected) {
|
||||
const indicatorContainer = document.createElement('span');
|
||||
indicatorContainer.className = 'ml-auto flex items-center gap-1';
|
||||
|
||||
// Add star for highlighted items
|
||||
if (item.isHighlighted) {
|
||||
const starIcon = document.createElement('span');
|
||||
starIcon.className = 'text-dynamic-primary';
|
||||
starIcon.innerHTML = '<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
||||
indicatorContainer.appendChild(starIcon);
|
||||
}
|
||||
|
||||
// Add checkmark for selected items (not highlighted)
|
||||
if (isSelected && !item.isHighlighted) {
|
||||
const checkIcon = document.createElement('span');
|
||||
checkIcon.className = 'text-dynamic-primary';
|
||||
checkIcon.innerHTML = '<svg class="w-2.5 h-2.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
||||
indicatorContainer.appendChild(checkIcon);
|
||||
}
|
||||
|
||||
itemElement.appendChild(indicatorContainer);
|
||||
}
|
||||
|
||||
container.appendChild(itemElement);
|
||||
|
||||
// Recursively render children
|
||||
if (!item.isFile && Object.keys(item.children).length > 0) {
|
||||
this.renderTreeNode(item.children, container, level + 1, fullPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.embedPreview = new EmbedPreview();
|
||||
});
|
||||
|
||||
// Expose API for external usage
|
||||
window.EmbedPreviewAPI = {
|
||||
getPrompt: () => window.embedPreview?.config.prompt,
|
||||
setPrompt: (prompt) => {
|
||||
if (window.embedPreview) {
|
||||
window.embedPreview.config.prompt = prompt;
|
||||
window.embedPreview.renderPromptText();
|
||||
}
|
||||
},
|
||||
updateConfig: (config) => {
|
||||
if (window.embedPreview) {
|
||||
Object.assign(window.embedPreview.config, config);
|
||||
window.embedPreview.render();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,251 +0,0 @@
|
||||
/* Custom CSS variables for dynamic theming */
|
||||
:root {
|
||||
/* These will be dynamically set by JavaScript based on light/dark colors */
|
||||
--primary: 59 130 246; /* Default blue-500 */
|
||||
--background: 255 255 255;
|
||||
--foreground: 15 23 42;
|
||||
--muted: 248 250 252;
|
||||
--muted-foreground: 100 116 139;
|
||||
--border: 226 232 240;
|
||||
--accent: 16 185 129;
|
||||
}
|
||||
|
||||
/* Dynamic color classes */
|
||||
.bg-dynamic-background { background-color: rgb(var(--background)); }
|
||||
.bg-dynamic-muted { background-color: rgb(var(--muted)); }
|
||||
.bg-dynamic-primary { background-color: rgb(var(--primary)); }
|
||||
.bg-dynamic-accent { background-color: rgb(var(--accent)); }
|
||||
.text-dynamic-foreground { color: rgb(var(--foreground)); }
|
||||
.text-dynamic-muted-foreground { color: rgb(var(--muted-foreground)); }
|
||||
.text-dynamic-primary { color: rgb(var(--primary)); }
|
||||
.text-dynamic-accent { color: rgb(var(--accent)); }
|
||||
.border-dynamic-border { border-color: rgb(var(--border)); }
|
||||
.border-dynamic-primary { border-color: rgb(var(--primary)); }
|
||||
|
||||
/* Dynamic color opacity variants for pills */
|
||||
.bg-dynamic-primary\/10 { background-color: rgb(var(--primary) / 0.1); }
|
||||
.bg-dynamic-primary\/20 { background-color: rgb(var(--primary) / 0.2); }
|
||||
.border-dynamic-primary\/20 { border-color: rgb(var(--primary) / 0.2); }
|
||||
.border-dynamic-primary\/30 { border-color: rgb(var(--primary) / 0.3); }
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Mention highlighting */
|
||||
.mention {
|
||||
background-color: rgb(var(--primary) / 0.1);
|
||||
color: rgb(var(--primary));
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.focus-ring:focus {
|
||||
outline: 2px solid rgb(var(--primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Viewer mode specific styles */
|
||||
.viewer-mode .prompt-input {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.viewer-mode .prompt-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for horizontal scrolling pills */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Touch target improvements for mobile */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Fixed height for prompt container to enable scrolling */
|
||||
#prompt-container {
|
||||
min-height: 100px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#prompt-container {
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure prompt text scrolls within container */
|
||||
#prompt-text {
|
||||
height: 100%;
|
||||
overflow-y: auto !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Responsive improvements */
|
||||
@media (max-width: 640px) {
|
||||
/* Context pills can wrap on mobile too */
|
||||
#context-pills {
|
||||
flex-wrap: wrap !important;
|
||||
max-height: 80px; /* Smaller on mobile */
|
||||
}
|
||||
|
||||
/* More compact pill spacing on mobile */
|
||||
.pill,
|
||||
#settings-pills .pill,
|
||||
#settings-pills > * {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Ensure button maintains minimum touch target */
|
||||
#copy-button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Adjust text size in prompt area for better mobile readability */
|
||||
#prompt-text {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Make sure settings pills don't overflow */
|
||||
#settings-pills {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Compact layout adjustments */
|
||||
.viewer-mode {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small mobile devices */
|
||||
@media (max-width: 480px) {
|
||||
/* Even more compact spacing */
|
||||
.pill,
|
||||
#settings-pills .pill,
|
||||
#settings-pills > * {
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
/* Smaller button on very small screens */
|
||||
#copy-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
#copy-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide file sidebar on mobile devices */
|
||||
@media (max-width: 640px) {
|
||||
#file-sidebar {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Context pills container improvements */
|
||||
#context-pills {
|
||||
max-width: calc(100% - 40px); /* Reserve space for edit button */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-height: 120px; /* Limit context pills height */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for context pills */
|
||||
#context-pills {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
}
|
||||
|
||||
#context-pills::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
#context-pills::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#context-pills::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border) / 0.5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#context-pills::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--border));
|
||||
}
|
||||
|
||||
/* Ensure pills maintain size */
|
||||
#context-pills > div {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Ensure proper touch behavior */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* All interactive elements get proper touch targets */
|
||||
button,
|
||||
.pill,
|
||||
#settings-pills > * {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Remove hover effects on touch devices */
|
||||
button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Add active states for better feedback */
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
layout: embed-preview
|
||||
title: "Prompt Viewer"
|
||||
permalink: /embed-preview/
|
||||
mode: viewer
|
||||
---
|
||||
1028
embed-script.js
1028
embed-script.js
File diff suppressed because it is too large
Load Diff
764
embed-style.css
764
embed-style.css
@@ -1,764 +0,0 @@
|
||||
/* Custom CSS variables for dynamic theming */
|
||||
:root {
|
||||
/* These will be dynamically set by JavaScript based on light/dark colors */
|
||||
--primary: 59 130 246; /* Default blue-500 */
|
||||
--background: 255 255 255;
|
||||
--foreground: 15 23 42;
|
||||
--muted: 248 250 252;
|
||||
--muted-foreground: 100 116 139;
|
||||
--border: 226 232 240;
|
||||
--accent: 16 185 129;
|
||||
|
||||
/* Main site theme variables */
|
||||
--bg-color-light: #ffffff;
|
||||
--bg-color-dark: #1a1a1a;
|
||||
--text-color-light: #000000;
|
||||
--text-color-dark: #ffffff;
|
||||
--header-height: 80px;
|
||||
--accent-color: #10b981;
|
||||
--accent-color-hover: #059669;
|
||||
--border-color: #e1e4e8;
|
||||
--border-color-dark: #2d2d2d;
|
||||
}
|
||||
|
||||
/* Reset body margin and ensure full height */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Allow scrolling on mobile */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
overflow: auto !important;
|
||||
height: auto !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Site Header Styles */
|
||||
.site-header {
|
||||
padding: 1rem 2rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-color-light);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
}
|
||||
|
||||
body.dark-mode .site-header {
|
||||
background-color: var(--bg-color-dark);
|
||||
border-bottom-color: var(--border-color-dark);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(45deg, var(--accent-color), #3b82f6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.site-title:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.site-slogan {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
body.dark-mode .site-slogan {
|
||||
color: var(--text-color-dark);
|
||||
}
|
||||
|
||||
.dark-mode-toggle {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.dark-mode-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Adjust designer content to account for header */
|
||||
.designer-content {
|
||||
height: 100vh;
|
||||
padding-top: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Override height on mobile to allow scrolling */
|
||||
@media (max-width: 768px) {
|
||||
.designer-content {
|
||||
height: auto !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dynamic color classes */
|
||||
.bg-dynamic-background { background-color: rgb(var(--background)); }
|
||||
.bg-dynamic-muted { background-color: rgb(var(--muted)); }
|
||||
.bg-dynamic-primary { background-color: rgb(var(--primary)); }
|
||||
.bg-dynamic-accent { background-color: rgb(var(--accent)); }
|
||||
.text-dynamic-foreground { color: rgb(var(--foreground)); }
|
||||
.text-dynamic-muted-foreground { color: rgb(var(--muted-foreground)); }
|
||||
.text-dynamic-primary { color: rgb(var(--primary)); }
|
||||
.text-dynamic-accent { color: rgb(var(--accent)); }
|
||||
.border-dynamic-border { border-color: rgb(var(--border)); }
|
||||
.border-dynamic-primary { border-color: rgb(var(--primary)); }
|
||||
|
||||
/* Dynamic color opacity variants for pills */
|
||||
.bg-dynamic-primary\/10 { background-color: rgb(var(--primary) / 0.1); }
|
||||
.bg-dynamic-primary\/20 { background-color: rgb(var(--primary) / 0.2); }
|
||||
.border-dynamic-primary\/20 { border-color: rgb(var(--primary) / 0.2); }
|
||||
.border-dynamic-primary\/30 { border-color: rgb(var(--primary) / 0.3); }
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Mention highlighting */
|
||||
.mention {
|
||||
background-color: rgb(var(--primary) / 0.1);
|
||||
color: rgb(var(--primary));
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.focus-ring:focus {
|
||||
outline: 2px solid rgb(var(--primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
body.dark-mode {
|
||||
background-color: var(--bg-color-dark);
|
||||
color: var(--text-color-dark);
|
||||
}
|
||||
|
||||
body.dark-mode .bg-dynamic-background {
|
||||
background-color: var(--bg-color-dark);
|
||||
}
|
||||
|
||||
body.dark-mode .bg-dynamic-muted {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
body.dark-mode .text-dynamic-foreground {
|
||||
color: var(--text-color-dark);
|
||||
}
|
||||
|
||||
body.dark-mode .text-dynamic-muted-foreground {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
body.dark-mode .border-dynamic-border {
|
||||
border-color: var(--border-color-dark);
|
||||
}
|
||||
|
||||
/* Designer mode specific styles */
|
||||
.designer-panel {
|
||||
border-right: 1px solid var(--border-color);
|
||||
background-color: #f8fafb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
body.dark-mode .designer-panel {
|
||||
background-color: #262626;
|
||||
border-right-color: var(--border-color-dark);
|
||||
}
|
||||
|
||||
/* Adjust the scrollable content area */
|
||||
.designer-panel > .flex-1 {
|
||||
padding-top: calc(var(--header-height));
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Ensure the bottom generate buttons section stays at bottom */
|
||||
.designer-panel > .p-6:last-child {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: rgb(var(--muted));
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
body.dark-mode .designer-panel > .p-6:last-child {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
/* Ensure textarea can be full height */
|
||||
#designer-prompt {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.viewer-mode .prompt-input {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.viewer-mode .prompt-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Theme button styles */
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem; /* gap-1 */
|
||||
padding: 0.5rem; /* p-2 */
|
||||
border-radius: 0.5rem; /* rounded-lg */
|
||||
border: 1px solid rgb(var(--border));
|
||||
transition: border-color 0.15s ease; /* transition-colors */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
border-color: rgb(var(--primary));
|
||||
}
|
||||
|
||||
.theme-btn.active {
|
||||
border-color: rgb(var(--primary));
|
||||
background-color: rgb(var(--primary) / 0.05);
|
||||
}
|
||||
|
||||
/* Responsive design improvements */
|
||||
@media (max-width: 1024px) {
|
||||
.designer-content {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.designer-panel {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgb(var(--border));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.designer-panel > .flex-1 {
|
||||
padding-top: 1rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Ensure the preview area doesn't take full height on mobile */
|
||||
.designer-content > .flex-1 {
|
||||
min-height: 300px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Range slider styles */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-track {
|
||||
background: rgb(var(--border));
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-track {
|
||||
background: rgb(var(--border));
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgb(var(--primary));
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
margin-top: -6px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
border: none;
|
||||
background: rgb(var(--primary));
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
input[type="range"]:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
input[type="range"]:hover::-moz-range-thumb {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
input[type="range"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]:focus::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 3px rgb(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
input[type="range"]:focus::-moz-range-thumb {
|
||||
box-shadow: 0 0 0 3px rgb(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
/* Color picker styles */
|
||||
input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Color preset button styles */
|
||||
.color-preset {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.color-preset:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.color-preset.selected {
|
||||
border-color: rgb(var(--primary)) !important;
|
||||
box-shadow: 0 0 0 3px rgb(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
/* Theme mode button active state */
|
||||
.theme-mode-btn {
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-mode-btn:hover:not(.bg-dynamic-primary) {
|
||||
background-color: rgb(var(--muted));
|
||||
border-color: rgb(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.theme-mode-btn.bg-dynamic-primary {
|
||||
box-shadow: 0 2px 4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Improve color input layout */
|
||||
.color-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Better visual hierarchy for labels */
|
||||
label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Dark mode iframe snippet styling */
|
||||
body.dark-mode #iframe-snippet {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
body.dark-mode #iframe-snippet:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* Mobile Responsive Styles */
|
||||
/* ============================== */
|
||||
|
||||
/* Touch target improvements for mobile */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.touch-target {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile menu toggle button */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Mobile close button - hidden by default */
|
||||
.mobile-close-btn {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(var(--header-height) + 1rem);
|
||||
right: 1rem;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgb(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-close-btn:hover {
|
||||
background: rgb(var(--muted));
|
||||
color: rgb(var(--foreground));
|
||||
}
|
||||
|
||||
.mobile-close-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Mobile overlay */
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-overlay.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
/* Header adjustments */
|
||||
.site-header {
|
||||
padding: 1rem;
|
||||
height: 60px;
|
||||
--header-height: 60px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.site-slogan {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Simplified mobile layout - stack vertically */
|
||||
.designer-content {
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
min-height: 100vh;
|
||||
padding-top: 60px; /* Account for fixed header */
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.designer-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgb(var(--border));
|
||||
order: 2; /* Bottom on mobile */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.designer-panel > .flex-1 {
|
||||
padding-top: 1rem;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Preview adjustments */
|
||||
.designer-content > .flex-1 {
|
||||
order: 1; /* Top on mobile */
|
||||
padding: 1rem;
|
||||
min-height: 300px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#preview-wrapper {
|
||||
max-width: 100%;
|
||||
height: 250px; /* Fixed height on mobile */
|
||||
}
|
||||
|
||||
/* Form element adjustments */
|
||||
.designer-panel .p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="color"],
|
||||
textarea,
|
||||
select {
|
||||
font-size: 16px; /* Prevent zoom on iOS */
|
||||
}
|
||||
|
||||
/* Make buttons more touch-friendly */
|
||||
button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.color-preset {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* Modal adjustments */
|
||||
#embed-modal > div {
|
||||
margin: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
/* Notification adjustments */
|
||||
#notification {
|
||||
top: auto;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile devices */
|
||||
@media (max-width: 480px) {
|
||||
.site-header {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Stack color inputs vertically */
|
||||
.flex.items-center.gap-3 {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Stack grid columns on very small screens */
|
||||
.grid.grid-cols-2,
|
||||
.grid.grid-cols-3,
|
||||
.grid.grid-cols-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
/* Adjust modal padding */
|
||||
#embed-modal > div {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Make range slider easier to use */
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape orientation adjustments */
|
||||
@media (max-height: 600px) and (orientation: landscape) {
|
||||
.site-header {
|
||||
height: 50px;
|
||||
--header-height: 50px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.designer-panel {
|
||||
max-height: calc(100vh - 50px);
|
||||
}
|
||||
|
||||
.designer-panel .flex-1 {
|
||||
max-height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
#designer-prompt {
|
||||
min-height: 100px;
|
||||
rows: 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device optimizations */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* Increase tap targets */
|
||||
button,
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
select {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Remove hover effects on touch devices */
|
||||
.theme-mode-btn:hover:not(.bg-dynamic-primary),
|
||||
.color-preset:hover,
|
||||
button:hover {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Add active states for better feedback */
|
||||
button:active,
|
||||
.theme-mode-btn:active,
|
||||
.color-preset:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.border-dynamic-border {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right Panel - Preview adjustments */
|
||||
.designer-content > .flex-1 {
|
||||
padding-top: var(--header-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
layout: embed
|
||||
title: "Prompt Designer"
|
||||
permalink: /embed/
|
||||
---
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
432
messages/ar.json
Normal file
432
messages/ar.json
Normal file
@@ -0,0 +1,432 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "جاري التحميل...",
|
||||
"error": "حدث خطأ",
|
||||
"somethingWentWrong": "حدث خطأ ما",
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء",
|
||||
"delete": "حذف",
|
||||
"edit": "تعديل",
|
||||
"create": "إنشاء",
|
||||
"search": "بحث",
|
||||
"filter": "تصفية",
|
||||
"sort": "ترتيب",
|
||||
"view": "عرض",
|
||||
"copy": "نسخ",
|
||||
"copied": "تم النسخ!",
|
||||
"copiedToClipboard": "تم النسخ إلى الحافظة",
|
||||
"failedToCopy": "فشل النسخ",
|
||||
"submit": "إرسال",
|
||||
"back": "رجوع",
|
||||
"next": "التالي",
|
||||
"previous": "السابق",
|
||||
"yes": "نعم",
|
||||
"no": "لا",
|
||||
"confirm": "تأكيد",
|
||||
"close": "إغلاق",
|
||||
"all": "الكل",
|
||||
"none": "لا شيء",
|
||||
"moreLines": "+{count} سطر إضافي"
|
||||
},
|
||||
"nav": {
|
||||
"home": "الرئيسية",
|
||||
"prompts": "الأوامر",
|
||||
"categories": "التصنيفات",
|
||||
"tags": "الوسوم",
|
||||
"settings": "الإعدادات",
|
||||
"admin": "الإدارة",
|
||||
"profile": "الملف الشخصي",
|
||||
"login": "تسجيل الدخول",
|
||||
"register": "إنشاء حساب",
|
||||
"logout": "تسجيل الخروج"
|
||||
},
|
||||
"auth": {
|
||||
"login": "تسجيل الدخول",
|
||||
"loginDescription": "أدخل بياناتك للمتابعة",
|
||||
"register": "إنشاء حساب",
|
||||
"registerDescription": "أنشئ حساباً للبدء",
|
||||
"logout": "تسجيل الخروج",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"confirmPassword": "تأكيد كلمة المرور",
|
||||
"username": "اسم المستخدم",
|
||||
"name": "الاسم",
|
||||
"forgotPassword": "نسيت كلمة المرور؟",
|
||||
"noAccount": "ليس لديك حساب؟",
|
||||
"hasAccount": "لديك حساب بالفعل؟",
|
||||
"signInWith": "تسجيل الدخول باستخدام {provider}",
|
||||
"orContinueWith": "أو المتابعة باستخدام",
|
||||
"loginSuccess": "تم تسجيل الدخول بنجاح",
|
||||
"registerSuccess": "تم إنشاء الحساب بنجاح",
|
||||
"logoutSuccess": "تم تسجيل الخروج بنجاح",
|
||||
"invalidCredentials": "البريد الإلكتروني أو كلمة المرور غير صحيحة",
|
||||
"emailTaken": "البريد الإلكتروني مستخدم بالفعل",
|
||||
"usernameTaken": "اسم المستخدم مستخدم بالفعل",
|
||||
"passwordMismatch": "كلمات المرور غير متطابقة",
|
||||
"passwordTooShort": "يجب أن تكون كلمة المرور 6 أحرف على الأقل",
|
||||
"registrationFailed": "فشل إنشاء الحساب"
|
||||
},
|
||||
"prompts": {
|
||||
"title": "الأوامر",
|
||||
"create": "إنشاء أمر",
|
||||
"edit": "تعديل الأمر",
|
||||
"delete": "حذف الأمر",
|
||||
"myPrompts": "أوامري",
|
||||
"publicPrompts": "الأوامر العامة",
|
||||
"privatePrompts": "الأوامر الخاصة",
|
||||
"noPrompts": "لم يتم العثور على أوامر",
|
||||
"noPromptsDescription": "حاول تعديل معايير البحث أو التصفية للعثور على ما تبحث عنه.",
|
||||
"noMorePrompts": "وصلت إلى النهاية",
|
||||
"loading": "جاري التحميل...",
|
||||
"promptTitle": "العنوان",
|
||||
"promptContent": "المحتوى",
|
||||
"promptDescription": "الوصف",
|
||||
"promptType": "النوع",
|
||||
"promptCategory": "التصنيف",
|
||||
"promptTags": "الوسوم",
|
||||
"promptPrivate": "خاص",
|
||||
"promptPublic": "عام",
|
||||
"types": {
|
||||
"text": "نص",
|
||||
"image": "صورة",
|
||||
"video": "فيديو",
|
||||
"audio": "صوت",
|
||||
"structured": "منظم"
|
||||
},
|
||||
"structuredFormat": "التنسيق",
|
||||
"structuredFormatDescription": "اختر تنسيق الأمر المنظم",
|
||||
"structuredContentDescription": "حدد سير العمل أو الوكيل أو تكوين الأنبوب",
|
||||
"versions": "الإصدارات",
|
||||
"version": "إصدار",
|
||||
"versionsCount": "إصدارات",
|
||||
"contributors": "مساهمين",
|
||||
"currentVersion": "الإصدار الحالي",
|
||||
"versionHistory": "تاريخ الإصدارات",
|
||||
"noVersions": "لا يوجد تاريخ للإصدارات",
|
||||
"compare": "مقارنة",
|
||||
"compareVersions": "مقارنة الإصدارات",
|
||||
"compareFrom": "من",
|
||||
"compareTo": "إلى",
|
||||
"comparing": "مقارنة",
|
||||
"selectVersionsToCompare": "اختر الإصدارات للمقارنة",
|
||||
"compareWithCurrent": "مقارنة مع الحالي",
|
||||
"changeRequests": "طلبات التغيير",
|
||||
"createChangeRequest": "اقتراح تغييرات",
|
||||
"viewCount": "المشاهدات",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"updatedAt": "تاريخ التحديث",
|
||||
"copyPrompt": "نسخ الأمر",
|
||||
"sharePrompt": "مشاركة الأمر",
|
||||
"confirmDelete": "هل أنت متأكد من حذف هذا الأمر؟",
|
||||
"promptCreated": "تم إنشاء الأمر",
|
||||
"promptUpdated": "تم تحديث الأمر",
|
||||
"run": "تشغيل",
|
||||
"titleRequired": "العنوان مطلوب",
|
||||
"contentRequired": "المحتوى مطلوب",
|
||||
"titlePlaceholder": "أدخل عنوان الأمر",
|
||||
"descriptionPlaceholder": "وصف اختياري للأمر",
|
||||
"contentPlaceholder": "أدخل محتوى الأمر هنا...",
|
||||
"selectCategory": "اختر تصنيف",
|
||||
"noCategory": "بدون تصنيف",
|
||||
"mediaUrl": "رابط الوسائط",
|
||||
"mediaUrlPlaceholder": "https://...",
|
||||
"mediaUrlDescription": "أدخل رابط ملف الوسائط لهذا الأمر",
|
||||
"privateDescription": "الأوامر الخاصة مرئية لك فقط",
|
||||
"update": "تحديث",
|
||||
"createButton": "إنشاء",
|
||||
"pin": "تثبيت في الملف الشخصي",
|
||||
"unpin": "إلغاء التثبيت",
|
||||
"pinned": "تم التثبيت في الملف الشخصي",
|
||||
"unpinned": "تم إلغاء التثبيت من الملف الشخصي",
|
||||
"pinFailed": "فشل تحديث التثبيت",
|
||||
"pinnedPrompts": "مثبتة"
|
||||
},
|
||||
"changeRequests": {
|
||||
"title": "طلبات التغيير",
|
||||
"create": "إنشاء طلب تغيير",
|
||||
"createDescription": "اقترح تحسينات أو إصلاحات لهذا الأمر",
|
||||
"backToPrompt": "العودة للأمر",
|
||||
"currentContent": "المحتوى الحالي",
|
||||
"proposedChanges": "التغييرات المقترحة",
|
||||
"proposedTitle": "العنوان المقترح",
|
||||
"proposedContent": "المحتوى المقترح",
|
||||
"proposedContentPlaceholder": "أدخل التغييرات المقترحة للأمر...",
|
||||
"reason": "سبب التغييرات",
|
||||
"reasonPlaceholder": "اشرح لماذا تقترح هذه التغييرات...",
|
||||
"mustMakeChanges": "يجب إجراء تغيير واحد على الأقل",
|
||||
"submit": "إرسال طلب التغيير",
|
||||
"created": "تم إرسال طلب التغيير بنجاح",
|
||||
"status": "الحالة",
|
||||
"pending": "قيد الانتظار",
|
||||
"approved": "مقبول",
|
||||
"rejected": "مرفوض",
|
||||
"approve": "قبول",
|
||||
"reject": "رفض",
|
||||
"reviewNote": "ملاحظة المراجعة",
|
||||
"reviewNotePlaceholder": "أضف ملاحظة حول قرارك (اختياري)...",
|
||||
"reviewActions": "مراجعة طلب التغيير هذا",
|
||||
"optional": "اختياري",
|
||||
"forPrompt": "للأمر",
|
||||
"titleChange": "تغيير العنوان",
|
||||
"contentChanges": "تغييرات المحتوى",
|
||||
"diffDescription": "الأسطر الحمراء ستُحذف، الأسطر الخضراء ستُضاف",
|
||||
"approvedSuccess": "تم قبول طلب التغيير وتحديث الأمر",
|
||||
"rejectedSuccess": "تم رفض طلب التغيير",
|
||||
"reopen": "إعادة فتح",
|
||||
"reopenedSuccess": "تم إعادة فتح طلب التغيير",
|
||||
"noRequests": "لا توجد طلبات تغيير",
|
||||
"edit": "تعديل",
|
||||
"preview": "معاينة",
|
||||
"noChangesYet": "لا توجد تغييرات بعد",
|
||||
"changesDetected": "تم اكتشاف تغييرات"
|
||||
},
|
||||
"categories": {
|
||||
"title": "التصنيفات",
|
||||
"description": "تصفح واشترك في التصنيفات",
|
||||
"create": "إنشاء تصنيف",
|
||||
"edit": "تعديل التصنيف",
|
||||
"delete": "حذف التصنيف",
|
||||
"name": "الاسم",
|
||||
"parent": "التصنيف الأب",
|
||||
"noCategories": "لم يتم العثور على تصنيفات",
|
||||
"noSubcategories": "لا توجد تصنيفات فرعية في هذا التصنيف بعد",
|
||||
"prompts": "أوامر",
|
||||
"confirmDelete": "هل أنت متأكد من حذف هذا التصنيف؟"
|
||||
},
|
||||
"tags": {
|
||||
"title": "الوسوم",
|
||||
"description": "تصفح الأوامر حسب الوسوم",
|
||||
"create": "إنشاء وسم",
|
||||
"edit": "تعديل الوسم",
|
||||
"delete": "حذف الوسم",
|
||||
"name": "الاسم",
|
||||
"color": "اللون",
|
||||
"noTags": "لم يتم العثور على وسوم",
|
||||
"confirmDelete": "هل أنت متأكد من حذف هذا الوسم؟",
|
||||
"prompts": "أوامر",
|
||||
"allTags": "جميع الوسوم",
|
||||
"popularTags": "الوسوم الشائعة"
|
||||
},
|
||||
"settings": {
|
||||
"title": "الإعدادات",
|
||||
"description": "إدارة إعدادات حسابك وملفك الشخصي",
|
||||
"profile": "الملف الشخصي",
|
||||
"account": "الحساب",
|
||||
"appearance": "المظهر",
|
||||
"language": "اللغة",
|
||||
"theme": "السمة",
|
||||
"themeLight": "فاتح",
|
||||
"themeDark": "داكن",
|
||||
"themeSystem": "النظام",
|
||||
"saveSuccess": "تم حفظ الإعدادات بنجاح",
|
||||
"avatar": "الصورة الرمزية",
|
||||
"changeAvatar": "تغيير الصورة الرمزية"
|
||||
},
|
||||
"admin": {
|
||||
"title": "لوحة الإدارة",
|
||||
"description": "إدارة المستخدمين والتصنيفات والوسوم",
|
||||
"stats": {
|
||||
"users": "المستخدمون",
|
||||
"prompts": "الأوامر",
|
||||
"categories": "التصنيفات",
|
||||
"tags": "الوسوم"
|
||||
},
|
||||
"tabs": {
|
||||
"users": "المستخدمون",
|
||||
"categories": "التصنيفات",
|
||||
"tags": "الوسوم"
|
||||
},
|
||||
"users": {
|
||||
"title": "إدارة المستخدمين",
|
||||
"description": "عرض وإدارة حسابات المستخدمين",
|
||||
"user": "المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
"role": "الدور",
|
||||
"prompts": "الأوامر",
|
||||
"joined": "تاريخ الانضمام",
|
||||
"makeAdmin": "جعله مديراً",
|
||||
"removeAdmin": "إزالة صلاحيات المدير",
|
||||
"delete": "حذف",
|
||||
"cancel": "إلغاء",
|
||||
"deleted": "تم حذف المستخدم بنجاح",
|
||||
"deleteFailed": "فشل حذف المستخدم",
|
||||
"roleUpdated": "تم تحديث دور المستخدم",
|
||||
"roleUpdateFailed": "فشل تحديث الدور",
|
||||
"deleteConfirmTitle": "حذف المستخدم؟",
|
||||
"deleteConfirmDescription": "هذا الإجراء لا يمكن التراجع عنه. سيتم حذف جميع بيانات المستخدم نهائياً."
|
||||
},
|
||||
"categories": {
|
||||
"title": "إدارة التصنيفات",
|
||||
"description": "إنشاء وإدارة تصنيفات الأوامر",
|
||||
"name": "الاسم",
|
||||
"slug": "المعرف",
|
||||
"descriptionLabel": "الوصف",
|
||||
"icon": "الأيقونة",
|
||||
"parent": "الأب",
|
||||
"prompts": "الأوامر",
|
||||
"add": "إضافة تصنيف",
|
||||
"edit": "تعديل",
|
||||
"delete": "حذف",
|
||||
"cancel": "إلغاء",
|
||||
"save": "حفظ",
|
||||
"create": "إنشاء",
|
||||
"noCategories": "لا توجد تصنيفات بعد",
|
||||
"created": "تم إنشاء التصنيف بنجاح",
|
||||
"updated": "تم تحديث التصنيف بنجاح",
|
||||
"deleted": "تم حذف التصنيف بنجاح",
|
||||
"saveFailed": "فشل حفظ التصنيف",
|
||||
"deleteFailed": "فشل حذف التصنيف",
|
||||
"createTitle": "إنشاء تصنيف",
|
||||
"createDescription": "إضافة تصنيف جديد لتنظيم الأوامر",
|
||||
"editTitle": "تعديل التصنيف",
|
||||
"editDescription": "تحديث تفاصيل التصنيف",
|
||||
"deleteConfirmTitle": "حذف التصنيف؟",
|
||||
"deleteConfirmDescription": "سيؤدي هذا إلى إزالة التصنيف. ستصبح الأوامر في هذا التصنيف بدون تصنيف.",
|
||||
"parentCategory": "التصنيف الأب",
|
||||
"selectParent": "اختر تصنيف أب",
|
||||
"noParent": "بدون (تصنيف رئيسي)",
|
||||
"parentHelp": "اتركه فارغاً لإنشاء تصنيف رئيسي، أو اختر تصنيف أب لإنشاء تصنيف فرعي",
|
||||
"rootCategory": "رئيسي",
|
||||
"subcategories": "تصنيفات فرعية"
|
||||
},
|
||||
"tags": {
|
||||
"title": "إدارة الوسوم",
|
||||
"description": "إنشاء وإدارة وسوم الأوامر",
|
||||
"name": "الاسم",
|
||||
"slug": "المعرف",
|
||||
"color": "اللون",
|
||||
"prompts": "الأوامر",
|
||||
"add": "إضافة وسم",
|
||||
"edit": "تعديل",
|
||||
"delete": "حذف",
|
||||
"cancel": "إلغاء",
|
||||
"save": "حفظ",
|
||||
"create": "إنشاء",
|
||||
"noTags": "لا توجد وسوم بعد",
|
||||
"created": "تم إنشاء الوسم بنجاح",
|
||||
"updated": "تم تحديث الوسم بنجاح",
|
||||
"deleted": "تم حذف الوسم بنجاح",
|
||||
"saveFailed": "فشل حفظ الوسم",
|
||||
"deleteFailed": "فشل حذف الوسم",
|
||||
"createTitle": "إنشاء وسم",
|
||||
"createDescription": "إضافة وسم جديد لتصنيف الأوامر",
|
||||
"editTitle": "تعديل الوسم",
|
||||
"editDescription": "تحديث تفاصيل الوسم",
|
||||
"deleteConfirmTitle": "حذف الوسم؟",
|
||||
"deleteConfirmDescription": "سيؤدي هذا إلى إزالة الوسم من جميع الأوامر."
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "البحث في الأوامر...",
|
||||
"advanced": "بحث متقدم",
|
||||
"filters": "الفلاتر",
|
||||
"results": "النتائج",
|
||||
"noResults": "لم يتم العثور على نتائج",
|
||||
"searchIn": "البحث في",
|
||||
"sortBy": "ترتيب حسب",
|
||||
"relevance": "الصلة",
|
||||
"newest": "الأحدث",
|
||||
"oldest": "الأقدم",
|
||||
"mostUpvoted": "الأكثر تصويتاً",
|
||||
"search": "بحث",
|
||||
"clear": "مسح",
|
||||
"found": "تم العثور على {count}"
|
||||
},
|
||||
"user": {
|
||||
"profile": "الملف الشخصي",
|
||||
"prompts": "الأوامر",
|
||||
"myPrompts": "أوامري",
|
||||
"allPrompts": "جميع الأوامر",
|
||||
"joined": "تاريخ الانضمام",
|
||||
"noPrompts": "لا توجد أوامر بعد",
|
||||
"noPromptsOwner": "لم تقم بإنشاء أي أوامر بعد",
|
||||
"createFirstPrompt": "أنشئ أول أمر لك",
|
||||
"upvotesReceived": "تصويتات مستلمة",
|
||||
"editProfile": "تعديل الملف الشخصي"
|
||||
},
|
||||
"subscription": {
|
||||
"subscribe": "اشتراك",
|
||||
"unsubscribe": "إلغاء الاشتراك",
|
||||
"subscribedTo": "تم الاشتراك في {name}",
|
||||
"unsubscribedFrom": "تم إلغاء الاشتراك من {name}",
|
||||
"loginToSubscribe": "يرجى تسجيل الدخول للاشتراك"
|
||||
},
|
||||
"vote": {
|
||||
"loginToVote": "يرجى تسجيل الدخول للتصويت",
|
||||
"upvote": "تصويت",
|
||||
"upvotes": "تصويتات"
|
||||
},
|
||||
"version": {
|
||||
"newVersion": "إصدار جديد",
|
||||
"createVersion": "إنشاء إصدار",
|
||||
"createNewVersion": "إنشاء إصدار جديد",
|
||||
"updateDescription": "حدّث محتوى الأمر وأضف ملاحظة تصف تغييراتك.",
|
||||
"promptContent": "محتوى الأمر",
|
||||
"changeNote": "ملاحظة التغيير (اختياري)",
|
||||
"changeNotePlaceholder": "مثال: إصلاح خطأ إملائي، إضافة سياق...",
|
||||
"contentPlaceholder": "أدخل محتوى الأمر المحدث...",
|
||||
"contentMustDiffer": "يجب أن يختلف المحتوى عن الإصدار الحالي",
|
||||
"versionCreated": "تم إنشاء إصدار جديد",
|
||||
"deleteVersion": "حذف الإصدار",
|
||||
"confirmDeleteVersion": "هل أنت متأكد من حذف الإصدار {version}؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"versionDeleted": "تم حذف الإصدار بنجاح"
|
||||
},
|
||||
"profile": {
|
||||
"title": "الملف الشخصي",
|
||||
"updateInfo": "تحديث معلومات ملفك الشخصي",
|
||||
"avatarUrl": "رابط الصورة الرمزية",
|
||||
"displayName": "الاسم المعروض",
|
||||
"namePlaceholder": "اسمك",
|
||||
"username": "اسم المستخدم",
|
||||
"usernamePlaceholder": "اسم_المستخدم",
|
||||
"profileUrl": "رابط ملفك الشخصي",
|
||||
"email": "البريد الإلكتروني",
|
||||
"emailCannotChange": "لا يمكن تغيير البريد الإلكتروني",
|
||||
"saveChanges": "حفظ التغييرات",
|
||||
"profileUpdated": "تم تحديث الملف الشخصي بنجاح",
|
||||
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل"
|
||||
},
|
||||
"feed": {
|
||||
"yourFeed": "خلاصتك",
|
||||
"feedDescription": "أوامر من التصنيفات المشترك بها",
|
||||
"browseAll": "تصفح الكل",
|
||||
"noPromptsInFeed": "لا توجد أوامر في خلاصتك",
|
||||
"subscribeToCategories": "اشترك في التصنيفات لرؤية الأوامر هنا",
|
||||
"viewAllCategories": "عرض جميع التصنيفات"
|
||||
},
|
||||
"homepage": {
|
||||
"heroTitle": "اجمع، نظّم وشارك",
|
||||
"heroSubtitle": "أوامر الذكاء الاصطناعي",
|
||||
"heroDescription": "منصة مفتوحة المصدر لإدارة أوامر الذكاء الاصطناعي. استضافة ذاتية باستخدام Docker.",
|
||||
"browsePrompts": "تصفح الأوامر",
|
||||
"readyToStart": "مستعد للبدء؟",
|
||||
"freeAndOpen": "مجاني ومفتوح المصدر.",
|
||||
"createAccount": "إنشاء حساب"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "الإشعارات",
|
||||
"pendingChangeRequests": "طلبات التغيير المعلقة",
|
||||
"noNotifications": "لا توجد إشعارات"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "الصفحة غير موجودة",
|
||||
"unauthorized": "غير مصرح",
|
||||
"forbidden": "محظور",
|
||||
"serverError": "خطأ في الخادم",
|
||||
"networkError": "خطأ في الشبكة"
|
||||
},
|
||||
"diff": {
|
||||
"tokens": "رموز",
|
||||
"noChanges": "لا توجد تغييرات"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "الصفحة غير موجودة",
|
||||
"description": "الصفحة التي تبحث عنها غير موجودة أو تم نقلها.",
|
||||
"goHome": "الرئيسية",
|
||||
"goBack": "رجوع",
|
||||
"helpfulLinks": "إليك بعض الروابط المفيدة:",
|
||||
"browsePrompts": "تصفح الأوامر",
|
||||
"categories": "التصنيفات",
|
||||
"createPrompt": "إنشاء أمر"
|
||||
}
|
||||
}
|
||||
471
messages/en.json
Normal file
471
messages/en.json
Normal file
@@ -0,0 +1,471 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
"somethingWentWrong": "Something went wrong",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"sort": "Sort",
|
||||
"view": "View",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"failedToCopy": "Failed to copy",
|
||||
"submit": "Submit",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"confirm": "Confirm",
|
||||
"close": "Close",
|
||||
"all": "All",
|
||||
"none": "None",
|
||||
"moreLines": "+{count} more lines"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"prompts": "Prompts",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags",
|
||||
"settings": "Settings",
|
||||
"admin": "Admin",
|
||||
"profile": "Profile",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"loginDescription": "Enter your credentials to continue",
|
||||
"register": "Register",
|
||||
"registerDescription": "Create an account to get started",
|
||||
"logout": "Logout",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"username": "Username",
|
||||
"name": "Name",
|
||||
"forgotPassword": "Forgot Password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"signInWith": "Sign in with {provider}",
|
||||
"orContinueWith": "Or continue with",
|
||||
"loginSuccess": "Login successful",
|
||||
"registerSuccess": "Registration successful",
|
||||
"logoutSuccess": "Logged out successfully",
|
||||
"invalidCredentials": "Invalid email or password",
|
||||
"emailTaken": "Email is already taken",
|
||||
"usernameTaken": "Username is already taken",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 6 characters",
|
||||
"registrationFailed": "Registration failed"
|
||||
},
|
||||
"prompts": {
|
||||
"title": "Prompts",
|
||||
"create": "Create Prompt",
|
||||
"edit": "Edit Prompt",
|
||||
"delete": "Delete Prompt",
|
||||
"myPrompts": "My Prompts",
|
||||
"publicPrompts": "Public Prompts",
|
||||
"privatePrompts": "Private Prompts",
|
||||
"noPrompts": "No prompts found",
|
||||
"noPromptsDescription": "Try adjusting your search or filter criteria to find what you're looking for.",
|
||||
"noMorePrompts": "You've reached the end",
|
||||
"loading": "Loading...",
|
||||
"promptTitle": "Title",
|
||||
"promptContent": "Content",
|
||||
"promptDescription": "Description",
|
||||
"promptType": "Type",
|
||||
"promptCategory": "Category",
|
||||
"promptTags": "Tags",
|
||||
"promptPrivate": "Private",
|
||||
"promptPublic": "Public",
|
||||
"types": {
|
||||
"text": "Text",
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"structured": "Structured",
|
||||
"document": "Document"
|
||||
},
|
||||
"structuredFormat": "Format",
|
||||
"structuredFormatDescription": "Select the format for your structured prompt",
|
||||
"structuredContentDescription": "Define your workflow, agent, or pipeline configuration",
|
||||
"versions": "Versions",
|
||||
"version": "version",
|
||||
"versionsCount": "versions",
|
||||
"contributors": "contributors",
|
||||
"currentVersion": "Current Version",
|
||||
"versionHistory": "Version History",
|
||||
"noVersions": "No version history",
|
||||
"compare": "Compare",
|
||||
"compareVersions": "Compare Versions",
|
||||
"compareFrom": "From",
|
||||
"compareTo": "To",
|
||||
"comparing": "Comparing",
|
||||
"selectVersionsToCompare": "Select versions to compare",
|
||||
"compareWithCurrent": "Compare with current",
|
||||
"changeRequests": "Change Requests",
|
||||
"createChangeRequest": "Propose Changes",
|
||||
"viewCount": "Views",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Updated",
|
||||
"copyPrompt": "Copy Prompt",
|
||||
"sharePrompt": "Share Prompt",
|
||||
"confirmDelete": "Are you sure you want to delete this prompt?",
|
||||
"promptCreated": "Prompt created",
|
||||
"promptUpdated": "Prompt updated",
|
||||
"run": "Run",
|
||||
"titleRequired": "Title is required",
|
||||
"contentRequired": "Content is required",
|
||||
"titlePlaceholder": "Enter a title for your prompt",
|
||||
"descriptionPlaceholder": "Optional description of your prompt",
|
||||
"contentPlaceholder": "Enter your prompt content here...",
|
||||
"selectCategory": "Select a category",
|
||||
"noCategory": "None",
|
||||
"mediaUrl": "Media URL",
|
||||
"mediaUrlPlaceholder": "https://...",
|
||||
"mediaUrlDescription": "Enter a URL to the media file for this prompt",
|
||||
"privateDescription": "Private prompts are only visible to you",
|
||||
"requiresMediaUpload": "Requires Media Upload",
|
||||
"requiresMediaUploadDescription": "Users need to provide media files to use this prompt",
|
||||
"requiredMediaType": "Media Type",
|
||||
"requiredMediaCount": "Number of Files",
|
||||
"requiresImage": "Requires {count} {count, plural, one {image} other {images}}",
|
||||
"requiresVideo": "Requires {count} {count, plural, one {video} other {videos}}",
|
||||
"requiresDocument": "Requires {count} {count, plural, one {document} other {documents}}",
|
||||
"update": "Update",
|
||||
"createButton": "Create",
|
||||
"pin": "Pin to Profile",
|
||||
"unpin": "Unpin",
|
||||
"pinned": "Pinned to profile",
|
||||
"unpinned": "Unpinned from profile",
|
||||
"pinFailed": "Failed to update pin",
|
||||
"pinnedPrompts": "Pinned"
|
||||
},
|
||||
"changeRequests": {
|
||||
"title": "Change Requests",
|
||||
"create": "Create Change Request",
|
||||
"createDescription": "Suggest improvements or fixes for this prompt",
|
||||
"backToPrompt": "Back to prompt",
|
||||
"currentContent": "Current content",
|
||||
"proposedChanges": "Proposed Changes",
|
||||
"proposedTitle": "Proposed Title",
|
||||
"proposedContent": "Proposed Content",
|
||||
"proposedContentPlaceholder": "Enter your proposed changes to the prompt...",
|
||||
"reason": "Reason for Changes",
|
||||
"reasonPlaceholder": "Explain why you're suggesting these changes...",
|
||||
"mustMakeChanges": "You must make at least one change",
|
||||
"submit": "Submit Change Request",
|
||||
"created": "Change request submitted successfully",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"reviewNote": "Review Note",
|
||||
"reviewNotePlaceholder": "Add a note about your decision (optional)...",
|
||||
"reviewActions": "Review this change request",
|
||||
"optional": "optional",
|
||||
"forPrompt": "For prompt",
|
||||
"titleChange": "Title Change",
|
||||
"contentChanges": "Content Changes",
|
||||
"diffDescription": "Red lines will be removed, green lines will be added",
|
||||
"approvedSuccess": "Change request approved and prompt updated",
|
||||
"rejectedSuccess": "Change request rejected",
|
||||
"reopen": "Reopen",
|
||||
"reopenedSuccess": "Change request reopened",
|
||||
"noRequests": "No change requests",
|
||||
"edit": "Edit",
|
||||
"preview": "Preview",
|
||||
"noChangesYet": "No changes yet",
|
||||
"changesDetected": "Changes detected"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
"description": "Browse and subscribe to categories",
|
||||
"create": "Create Category",
|
||||
"edit": "Edit Category",
|
||||
"delete": "Delete Category",
|
||||
"name": "Name",
|
||||
"parent": "Parent Category",
|
||||
"noCategories": "No categories found",
|
||||
"noSubcategories": "No subcategories in this category yet",
|
||||
"prompts": "prompts",
|
||||
"confirmDelete": "Are you sure you want to delete this category?"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
"description": "Browse prompts by tags",
|
||||
"create": "Create Tag",
|
||||
"edit": "Edit Tag",
|
||||
"delete": "Delete Tag",
|
||||
"name": "Name",
|
||||
"color": "Color",
|
||||
"noTags": "No tags found",
|
||||
"confirmDelete": "Are you sure you want to delete this tag?",
|
||||
"prompts": "prompts",
|
||||
"allTags": "All Tags",
|
||||
"popularTags": "Popular Tags"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"description": "Manage your account settings and profile",
|
||||
"profile": "Profile",
|
||||
"account": "Account",
|
||||
"appearance": "Appearance",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"themeSystem": "System",
|
||||
"saveSuccess": "Settings saved successfully",
|
||||
"avatar": "Avatar",
|
||||
"changeAvatar": "Change Avatar"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Dashboard",
|
||||
"description": "Manage users, categories, and tags",
|
||||
"stats": {
|
||||
"users": "Users",
|
||||
"prompts": "Prompts",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags"
|
||||
},
|
||||
"tabs": {
|
||||
"users": "Users",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags",
|
||||
"webhooks": "Webhooks"
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
"description": "View and manage user accounts",
|
||||
"user": "User",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"prompts": "Prompts",
|
||||
"joined": "Joined",
|
||||
"makeAdmin": "Make Admin",
|
||||
"removeAdmin": "Remove Admin",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"deleted": "User deleted successfully",
|
||||
"deleteFailed": "Failed to delete user",
|
||||
"roleUpdated": "User role updated",
|
||||
"roleUpdateFailed": "Failed to update role",
|
||||
"deleteConfirmTitle": "Delete User?",
|
||||
"deleteConfirmDescription": "This action cannot be undone. All user data will be permanently deleted."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Category Management",
|
||||
"description": "Create and manage prompt categories",
|
||||
"name": "Name",
|
||||
"slug": "Slug",
|
||||
"descriptionLabel": "Description",
|
||||
"icon": "Icon",
|
||||
"parent": "Parent",
|
||||
"prompts": "Prompts",
|
||||
"add": "Add Category",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"noCategories": "No categories yet",
|
||||
"created": "Category created successfully",
|
||||
"updated": "Category updated successfully",
|
||||
"deleted": "Category deleted successfully",
|
||||
"saveFailed": "Failed to save category",
|
||||
"deleteFailed": "Failed to delete category",
|
||||
"createTitle": "Create Category",
|
||||
"createDescription": "Add a new category for organizing prompts",
|
||||
"editTitle": "Edit Category",
|
||||
"editDescription": "Update category details",
|
||||
"deleteConfirmTitle": "Delete Category?",
|
||||
"deleteConfirmDescription": "This will remove the category. Prompts in this category will be uncategorized.",
|
||||
"parentCategory": "Parent Category",
|
||||
"selectParent": "Select a parent category",
|
||||
"noParent": "None (Root Category)",
|
||||
"parentHelp": "Leave empty to create a root category, or select a parent to create a subcategory",
|
||||
"rootCategory": "Root",
|
||||
"subcategories": "subcategories"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tag Management",
|
||||
"description": "Create and manage prompt tags",
|
||||
"name": "Name",
|
||||
"slug": "Slug",
|
||||
"color": "Color",
|
||||
"prompts": "Prompts",
|
||||
"add": "Add Tag",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"noTags": "No tags yet",
|
||||
"created": "Tag created successfully",
|
||||
"updated": "Tag updated successfully",
|
||||
"deleted": "Tag deleted successfully",
|
||||
"saveFailed": "Failed to save tag",
|
||||
"deleteFailed": "Failed to delete tag",
|
||||
"createTitle": "Create Tag",
|
||||
"createDescription": "Add a new tag for labeling prompts",
|
||||
"editTitle": "Edit Tag",
|
||||
"editDescription": "Update tag details",
|
||||
"deleteConfirmTitle": "Delete Tag?",
|
||||
"deleteConfirmDescription": "This will remove the tag from all prompts."
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhook Management",
|
||||
"description": "Configure webhooks to receive notifications when events occur",
|
||||
"name": "Name",
|
||||
"url": "Webhook URL",
|
||||
"method": "HTTP Method",
|
||||
"headers": "HTTP Headers",
|
||||
"events": "Events",
|
||||
"payload": "JSON Payload",
|
||||
"payloadHelp": "Use placeholders like {{PROMPT_TITLE}}",
|
||||
"placeholders": "Available Placeholders",
|
||||
"status": "Status",
|
||||
"enabled": "Enabled",
|
||||
"add": "Add Webhook",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"empty": "No webhooks configured",
|
||||
"addTitle": "Add Webhook",
|
||||
"addDescription": "Configure a new webhook endpoint",
|
||||
"editTitle": "Edit Webhook",
|
||||
"editDescription": "Update webhook configuration",
|
||||
"deleteConfirm": "Are you sure you want to delete this webhook?",
|
||||
"useSlackPreset": "Use Slack Preset",
|
||||
"test": "Test",
|
||||
"testSuccess": "Webhook test successful!",
|
||||
"testFailed": "Webhook test failed"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search prompts...",
|
||||
"advanced": "Advanced Search",
|
||||
"filters": "Filters",
|
||||
"results": "Results",
|
||||
"noResults": "No results found",
|
||||
"searchIn": "Search in",
|
||||
"sortBy": "Sort by",
|
||||
"relevance": "Relevance",
|
||||
"newest": "Newest",
|
||||
"oldest": "Oldest",
|
||||
"mostUpvoted": "Most Upvoted",
|
||||
"search": "Search",
|
||||
"clear": "Clear",
|
||||
"found": "{count} found"
|
||||
},
|
||||
"user": {
|
||||
"profile": "Profile",
|
||||
"prompts": "Prompts",
|
||||
"myPrompts": "My Prompts",
|
||||
"allPrompts": "All Prompts",
|
||||
"joined": "Joined",
|
||||
"noPrompts": "No prompts yet",
|
||||
"noPromptsOwner": "You haven't created any prompts yet",
|
||||
"createFirstPrompt": "Create your first prompt",
|
||||
"upvotesReceived": "upvotes received",
|
||||
"editProfile": "Edit Profile"
|
||||
},
|
||||
"subscription": {
|
||||
"subscribe": "Subscribe",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"subscribedTo": "Subscribed to {name}",
|
||||
"unsubscribedFrom": "Unsubscribed from {name}",
|
||||
"loginToSubscribe": "Please login to subscribe"
|
||||
},
|
||||
"vote": {
|
||||
"loginToVote": "Please login to upvote",
|
||||
"upvote": "upvote",
|
||||
"upvotes": "upvotes"
|
||||
},
|
||||
"version": {
|
||||
"newVersion": "New Version",
|
||||
"createVersion": "Create Version",
|
||||
"createNewVersion": "Create New Version",
|
||||
"updateDescription": "Update the prompt content and add a note describing your changes.",
|
||||
"promptContent": "Prompt Content",
|
||||
"changeNote": "Change Note (optional)",
|
||||
"changeNotePlaceholder": "e.g., Fixed typo, Added more context...",
|
||||
"contentPlaceholder": "Enter the updated prompt content...",
|
||||
"contentMustDiffer": "Content must be different from current version",
|
||||
"versionCreated": "New version created",
|
||||
"deleteVersion": "Delete Version",
|
||||
"confirmDeleteVersion": "Are you sure you want to delete version {version}? This action cannot be undone.",
|
||||
"versionDeleted": "Version deleted successfully"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"updateInfo": "Update your profile information",
|
||||
"avatarUrl": "Avatar URL",
|
||||
"displayName": "Display Name",
|
||||
"namePlaceholder": "Your name",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "username",
|
||||
"profileUrl": "Your profile URL",
|
||||
"email": "Email",
|
||||
"emailCannotChange": "Email cannot be changed",
|
||||
"saveChanges": "Save Changes",
|
||||
"profileUpdated": "Profile updated successfully",
|
||||
"usernameTaken": "This username is already taken"
|
||||
},
|
||||
"feed": {
|
||||
"yourFeed": "Your Feed",
|
||||
"feedDescription": "Prompts from your subscribed categories",
|
||||
"browseAll": "Browse All",
|
||||
"noPromptsInFeed": "No prompts in your feed",
|
||||
"subscribeToCategories": "Subscribe to categories to see prompts here",
|
||||
"viewAllCategories": "View All Categories"
|
||||
},
|
||||
"homepage": {
|
||||
"heroTitle": "Collect, Organize & Share",
|
||||
"heroSubtitle": "AI Prompts",
|
||||
"heroDescription": "Open-source platform for managing AI prompts. Self-host with Docker.",
|
||||
"browsePrompts": "Browse Prompts",
|
||||
"readyToStart": "Ready to get started?",
|
||||
"freeAndOpen": "Free and open source.",
|
||||
"createAccount": "Create Account"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"pendingChangeRequests": "Pending change requests",
|
||||
"noNotifications": "No notifications"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Page not found",
|
||||
"unauthorized": "Unauthorized",
|
||||
"forbidden": "Forbidden",
|
||||
"serverError": "Server error",
|
||||
"networkError": "Network error"
|
||||
},
|
||||
"diff": {
|
||||
"tokens": "tokens",
|
||||
"noChanges": "No changes"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page Not Found",
|
||||
"description": "The page you're looking for doesn't exist or has been moved.",
|
||||
"goHome": "Go Home",
|
||||
"goBack": "Go Back",
|
||||
"helpfulLinks": "Here are some helpful links:",
|
||||
"browsePrompts": "Browse Prompts",
|
||||
"categories": "Categories",
|
||||
"createPrompt": "Create Prompt"
|
||||
}
|
||||
}
|
||||
426
messages/es.json
Normal file
426
messages/es.json
Normal file
@@ -0,0 +1,426 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Cargando...",
|
||||
"error": "Ocurrió un error",
|
||||
"somethingWentWrong": "Algo salió mal",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"create": "Crear",
|
||||
"search": "Buscar",
|
||||
"filter": "Filtrar",
|
||||
"sort": "Ordenar",
|
||||
"view": "Ver",
|
||||
"copy": "Copiar",
|
||||
"copied": "¡Copiado!",
|
||||
"copiedToClipboard": "Copiado al portapapeles",
|
||||
"failedToCopy": "Error al copiar",
|
||||
"submit": "Enviar",
|
||||
"back": "Atrás",
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"confirm": "Confirmar",
|
||||
"close": "Cerrar",
|
||||
"all": "Todos",
|
||||
"none": "Ninguno",
|
||||
"moreLines": "+{count} líneas más"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"prompts": "Prompts",
|
||||
"categories": "Categorías",
|
||||
"tags": "Etiquetas",
|
||||
"settings": "Configuración",
|
||||
"admin": "Admin",
|
||||
"profile": "Perfil",
|
||||
"login": "Iniciar sesión",
|
||||
"register": "Registrarse",
|
||||
"logout": "Cerrar sesión"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
"loginDescription": "Ingresa tus credenciales para continuar",
|
||||
"register": "Registrarse",
|
||||
"registerDescription": "Crea una cuenta para comenzar",
|
||||
"logout": "Cerrar sesión",
|
||||
"email": "Correo electrónico",
|
||||
"password": "Contraseña",
|
||||
"confirmPassword": "Confirmar contraseña",
|
||||
"username": "Nombre de usuario",
|
||||
"name": "Nombre",
|
||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||
"noAccount": "¿No tienes una cuenta?",
|
||||
"hasAccount": "¿Ya tienes una cuenta?",
|
||||
"signInWith": "Iniciar sesión con {provider}",
|
||||
"orContinueWith": "O continuar con",
|
||||
"loginSuccess": "Inicio de sesión exitoso",
|
||||
"registerSuccess": "Registro exitoso",
|
||||
"logoutSuccess": "Sesión cerrada exitosamente",
|
||||
"invalidCredentials": "Correo o contraseña inválidos",
|
||||
"emailTaken": "El correo ya está en uso",
|
||||
"usernameTaken": "El nombre de usuario ya está en uso",
|
||||
"passwordMismatch": "Las contraseñas no coinciden",
|
||||
"passwordTooShort": "La contraseña debe tener al menos 6 caracteres",
|
||||
"registrationFailed": "Error en el registro"
|
||||
},
|
||||
"prompts": {
|
||||
"title": "Prompts",
|
||||
"create": "Crear Prompt",
|
||||
"edit": "Editar Prompt",
|
||||
"delete": "Eliminar Prompt",
|
||||
"myPrompts": "Mis Prompts",
|
||||
"publicPrompts": "Prompts Públicos",
|
||||
"privatePrompts": "Prompts Privados",
|
||||
"noPrompts": "No se encontraron prompts",
|
||||
"noPromptsDescription": "Intenta ajustar tu búsqueda o criterios de filtro para encontrar lo que buscas.",
|
||||
"promptTitle": "Título",
|
||||
"promptContent": "Contenido",
|
||||
"promptDescription": "Descripción",
|
||||
"promptType": "Tipo",
|
||||
"promptCategory": "Categoría",
|
||||
"promptTags": "Etiquetas",
|
||||
"promptPrivate": "Privado",
|
||||
"promptPublic": "Público",
|
||||
"types": {
|
||||
"text": "Texto",
|
||||
"image": "Imagen",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"structured": "Estructurado"
|
||||
},
|
||||
"structuredFormat": "Formato",
|
||||
"structuredFormatDescription": "Selecciona el formato para tu prompt estructurado",
|
||||
"structuredContentDescription": "Define tu flujo de trabajo, agente o configuración de pipeline",
|
||||
"versions": "Versiones",
|
||||
"version": "versión",
|
||||
"versionsCount": "versiones",
|
||||
"contributors": "colaboradores",
|
||||
"currentVersion": "Versión Actual",
|
||||
"versionHistory": "Historial de Versiones",
|
||||
"noVersions": "Sin historial de versiones",
|
||||
"compare": "Comparar",
|
||||
"compareVersions": "Comparar Versiones",
|
||||
"compareFrom": "Desde",
|
||||
"compareTo": "Hasta",
|
||||
"comparing": "Comparando",
|
||||
"selectVersionsToCompare": "Selecciona versiones para comparar",
|
||||
"compareWithCurrent": "Comparar con actual",
|
||||
"changeRequests": "Solicitudes de Cambio",
|
||||
"createChangeRequest": "Proponer Cambios",
|
||||
"viewCount": "Vistas",
|
||||
"createdAt": "Creado",
|
||||
"updatedAt": "Actualizado",
|
||||
"copyPrompt": "Copiar Prompt",
|
||||
"sharePrompt": "Compartir Prompt",
|
||||
"confirmDelete": "¿Estás seguro de que quieres eliminar este prompt?",
|
||||
"promptCreated": "Prompt creado",
|
||||
"promptUpdated": "Prompt actualizado",
|
||||
"run": "Ejecutar",
|
||||
"titleRequired": "El título es obligatorio",
|
||||
"contentRequired": "El contenido es obligatorio",
|
||||
"titlePlaceholder": "Ingresa un título para tu prompt",
|
||||
"descriptionPlaceholder": "Descripción opcional de tu prompt",
|
||||
"contentPlaceholder": "Ingresa el contenido de tu prompt aquí...",
|
||||
"selectCategory": "Selecciona una categoría",
|
||||
"noCategory": "Ninguna",
|
||||
"mediaUrl": "URL del Medio",
|
||||
"mediaUrlPlaceholder": "https://...",
|
||||
"mediaUrlDescription": "Ingresa una URL al archivo de medios para este prompt",
|
||||
"privateDescription": "Los prompts privados solo son visibles para ti",
|
||||
"update": "Actualizar",
|
||||
"createButton": "Crear",
|
||||
"pin": "Fijar en Perfil",
|
||||
"unpin": "Desfijar",
|
||||
"pinned": "Fijado en el perfil",
|
||||
"unpinned": "Desfijado del perfil",
|
||||
"pinFailed": "Error al actualizar fijado",
|
||||
"pinnedPrompts": "Fijados"
|
||||
},
|
||||
"changeRequests": {
|
||||
"title": "Solicitudes de Cambio",
|
||||
"create": "Crear Solicitud de Cambio",
|
||||
"createDescription": "Sugiere mejoras o correcciones para este prompt",
|
||||
"backToPrompt": "Volver al prompt",
|
||||
"currentContent": "Contenido actual",
|
||||
"proposedChanges": "Cambios Propuestos",
|
||||
"proposedTitle": "Título Propuesto",
|
||||
"proposedContent": "Contenido Propuesto",
|
||||
"proposedContentPlaceholder": "Ingresa los cambios propuestos para el prompt...",
|
||||
"reason": "Razón de los Cambios",
|
||||
"reasonPlaceholder": "Explica por qué sugieres estos cambios...",
|
||||
"mustMakeChanges": "Debes hacer al menos un cambio",
|
||||
"submit": "Enviar Solicitud de Cambio",
|
||||
"created": "Solicitud de cambio enviada exitosamente",
|
||||
"status": "Estado",
|
||||
"pending": "Pendiente",
|
||||
"approved": "Aprobado",
|
||||
"rejected": "Rechazado",
|
||||
"approve": "Aprobar",
|
||||
"reject": "Rechazar",
|
||||
"reviewNote": "Nota de Revisión",
|
||||
"reviewNotePlaceholder": "Agrega una nota sobre tu decisión (opcional)...",
|
||||
"reviewActions": "Revisar esta solicitud de cambio",
|
||||
"optional": "opcional",
|
||||
"forPrompt": "Para el prompt",
|
||||
"titleChange": "Cambio de Título",
|
||||
"contentChanges": "Cambios de Contenido",
|
||||
"diffDescription": "Las líneas rojas serán eliminadas, las líneas verdes serán agregadas",
|
||||
"approvedSuccess": "Solicitud de cambio aprobada y prompt actualizado",
|
||||
"rejectedSuccess": "Solicitud de cambio rechazada",
|
||||
"reopen": "Reabrir",
|
||||
"reopenedSuccess": "Solicitud de cambio reabierta",
|
||||
"noRequests": "Sin solicitudes de cambio",
|
||||
"edit": "Editar",
|
||||
"preview": "Vista previa",
|
||||
"noChangesYet": "Sin cambios aún",
|
||||
"changesDetected": "Cambios detectados"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categorías",
|
||||
"description": "Explora y suscríbete a categorías",
|
||||
"create": "Crear Categoría",
|
||||
"edit": "Editar Categoría",
|
||||
"delete": "Eliminar Categoría",
|
||||
"name": "Nombre",
|
||||
"parent": "Categoría Padre",
|
||||
"noCategories": "No se encontraron categorías",
|
||||
"noSubcategories": "Aún no hay subcategorías en esta categoría",
|
||||
"prompts": "prompts",
|
||||
"confirmDelete": "¿Estás seguro de que quieres eliminar esta categoría?"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Etiquetas",
|
||||
"create": "Crear Etiqueta",
|
||||
"edit": "Editar Etiqueta",
|
||||
"delete": "Eliminar Etiqueta",
|
||||
"name": "Nombre",
|
||||
"color": "Color",
|
||||
"noTags": "No se encontraron etiquetas",
|
||||
"confirmDelete": "¿Estás seguro de que quieres eliminar esta etiqueta?"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
"description": "Administra tu cuenta y perfil",
|
||||
"profile": "Perfil",
|
||||
"account": "Cuenta",
|
||||
"appearance": "Apariencia",
|
||||
"language": "Idioma",
|
||||
"theme": "Tema",
|
||||
"themeLight": "Claro",
|
||||
"themeDark": "Oscuro",
|
||||
"themeSystem": "Sistema",
|
||||
"saveSuccess": "Configuración guardada exitosamente",
|
||||
"avatar": "Avatar",
|
||||
"changeAvatar": "Cambiar Avatar"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Panel de Administración",
|
||||
"description": "Administra usuarios, categorías y etiquetas",
|
||||
"stats": {
|
||||
"users": "Usuarios",
|
||||
"prompts": "Prompts",
|
||||
"categories": "Categorías",
|
||||
"tags": "Etiquetas"
|
||||
},
|
||||
"tabs": {
|
||||
"users": "Usuarios",
|
||||
"categories": "Categorías",
|
||||
"tags": "Etiquetas"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestión de Usuarios",
|
||||
"description": "Ver y administrar cuentas de usuario",
|
||||
"user": "Usuario",
|
||||
"email": "Correo",
|
||||
"role": "Rol",
|
||||
"prompts": "Prompts",
|
||||
"joined": "Registrado",
|
||||
"makeAdmin": "Hacer Admin",
|
||||
"removeAdmin": "Quitar Admin",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar",
|
||||
"deleted": "Usuario eliminado exitosamente",
|
||||
"deleteFailed": "Error al eliminar usuario",
|
||||
"roleUpdated": "Rol de usuario actualizado",
|
||||
"roleUpdateFailed": "Error al actualizar rol",
|
||||
"deleteConfirmTitle": "¿Eliminar Usuario?",
|
||||
"deleteConfirmDescription": "Esta acción no se puede deshacer. Todos los datos del usuario serán eliminados permanentemente."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Gestión de Categorías",
|
||||
"description": "Crear y administrar categorías de prompts",
|
||||
"name": "Nombre",
|
||||
"slug": "Slug",
|
||||
"descriptionLabel": "Descripción",
|
||||
"icon": "Icono",
|
||||
"parent": "Padre",
|
||||
"prompts": "Prompts",
|
||||
"add": "Agregar Categoría",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"create": "Crear",
|
||||
"noCategories": "Aún no hay categorías",
|
||||
"created": "Categoría creada exitosamente",
|
||||
"updated": "Categoría actualizada exitosamente",
|
||||
"deleted": "Categoría eliminada exitosamente",
|
||||
"saveFailed": "Error al guardar categoría",
|
||||
"deleteFailed": "Error al eliminar categoría",
|
||||
"createTitle": "Crear Categoría",
|
||||
"createDescription": "Agrega una nueva categoría para organizar prompts",
|
||||
"editTitle": "Editar Categoría",
|
||||
"editDescription": "Actualizar detalles de la categoría",
|
||||
"deleteConfirmTitle": "¿Eliminar Categoría?",
|
||||
"deleteConfirmDescription": "Esto eliminará la categoría. Los prompts en esta categoría quedarán sin categoría.",
|
||||
"parentCategory": "Categoría Padre",
|
||||
"selectParent": "Selecciona una categoría padre",
|
||||
"noParent": "Ninguna (Categoría Raíz)",
|
||||
"parentHelp": "Deja vacío para crear una categoría raíz, o selecciona un padre para crear una subcategoría",
|
||||
"rootCategory": "Raíz",
|
||||
"subcategories": "subcategorías"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Gestión de Etiquetas",
|
||||
"description": "Crear y administrar etiquetas de prompts",
|
||||
"name": "Nombre",
|
||||
"slug": "Slug",
|
||||
"color": "Color",
|
||||
"prompts": "Prompts",
|
||||
"add": "Agregar Etiqueta",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"create": "Crear",
|
||||
"noTags": "Aún no hay etiquetas",
|
||||
"created": "Etiqueta creada exitosamente",
|
||||
"updated": "Etiqueta actualizada exitosamente",
|
||||
"deleted": "Etiqueta eliminada exitosamente",
|
||||
"saveFailed": "Error al guardar etiqueta",
|
||||
"deleteFailed": "Error al eliminar etiqueta",
|
||||
"createTitle": "Crear Etiqueta",
|
||||
"createDescription": "Agrega una nueva etiqueta para etiquetar prompts",
|
||||
"editTitle": "Editar Etiqueta",
|
||||
"editDescription": "Actualizar detalles de la etiqueta",
|
||||
"deleteConfirmTitle": "¿Eliminar Etiqueta?",
|
||||
"deleteConfirmDescription": "Esto eliminará la etiqueta de todos los prompts."
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar prompts...",
|
||||
"advanced": "Búsqueda Avanzada",
|
||||
"filters": "Filtros",
|
||||
"results": "Resultados",
|
||||
"noResults": "No se encontraron resultados",
|
||||
"searchIn": "Buscar en",
|
||||
"sortBy": "Ordenar por",
|
||||
"relevance": "Relevancia",
|
||||
"newest": "Más recientes",
|
||||
"oldest": "Más antiguos",
|
||||
"mostUpvoted": "Más votados",
|
||||
"search": "Buscar",
|
||||
"clear": "Limpiar",
|
||||
"found": "{count} encontrados"
|
||||
},
|
||||
"user": {
|
||||
"profile": "Perfil",
|
||||
"prompts": "Prompts",
|
||||
"myPrompts": "Mis Prompts",
|
||||
"allPrompts": "Todos los Prompts",
|
||||
"joined": "Registrado",
|
||||
"noPrompts": "Aún no hay prompts",
|
||||
"noPromptsOwner": "Aún no has creado ningún prompt",
|
||||
"createFirstPrompt": "Crea tu primer prompt",
|
||||
"upvotesReceived": "votos recibidos",
|
||||
"editProfile": "Editar Perfil"
|
||||
},
|
||||
"subscription": {
|
||||
"subscribe": "Suscribirse",
|
||||
"unsubscribe": "Cancelar suscripción",
|
||||
"subscribedTo": "Suscrito a {name}",
|
||||
"unsubscribedFrom": "Suscripción cancelada de {name}",
|
||||
"loginToSubscribe": "Por favor inicia sesión para suscribirte"
|
||||
},
|
||||
"vote": {
|
||||
"loginToVote": "Por favor inicia sesión para votar",
|
||||
"upvote": "voto",
|
||||
"upvotes": "votos"
|
||||
},
|
||||
"version": {
|
||||
"newVersion": "Nueva Versión",
|
||||
"createVersion": "Crear Versión",
|
||||
"createNewVersion": "Crear Nueva Versión",
|
||||
"updateDescription": "Actualiza el contenido del prompt y agrega una nota describiendo tus cambios.",
|
||||
"promptContent": "Contenido del Prompt",
|
||||
"changeNote": "Nota de Cambio (opcional)",
|
||||
"changeNotePlaceholder": "ej., Corregido error tipográfico, Agregado más contexto...",
|
||||
"contentPlaceholder": "Ingresa el contenido actualizado del prompt...",
|
||||
"contentMustDiffer": "El contenido debe ser diferente de la versión actual",
|
||||
"versionCreated": "Nueva versión creada",
|
||||
"deleteVersion": "Eliminar Versión",
|
||||
"confirmDeleteVersion": "¿Estás seguro de que quieres eliminar la versión {version}? Esta acción no se puede deshacer.",
|
||||
"versionDeleted": "Versión eliminada exitosamente"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Perfil",
|
||||
"updateInfo": "Actualiza tu información de perfil",
|
||||
"avatarUrl": "URL del Avatar",
|
||||
"displayName": "Nombre para Mostrar",
|
||||
"namePlaceholder": "Tu nombre",
|
||||
"username": "Nombre de usuario",
|
||||
"usernamePlaceholder": "nombre_usuario",
|
||||
"profileUrl": "URL de tu perfil",
|
||||
"email": "Correo electrónico",
|
||||
"emailCannotChange": "El correo no se puede cambiar",
|
||||
"saveChanges": "Guardar Cambios",
|
||||
"profileUpdated": "Perfil actualizado exitosamente",
|
||||
"usernameTaken": "Este nombre de usuario ya está en uso"
|
||||
},
|
||||
"feed": {
|
||||
"yourFeed": "Tu Feed",
|
||||
"feedDescription": "Prompts de tus categorías suscritas",
|
||||
"browseAll": "Ver Todos",
|
||||
"noPromptsInFeed": "No hay prompts en tu feed",
|
||||
"subscribeToCategories": "Suscríbete a categorías para ver prompts aquí",
|
||||
"viewAllCategories": "Ver Todas las Categorías"
|
||||
},
|
||||
"homepage": {
|
||||
"heroTitle": "Recolecta, Organiza y Comparte",
|
||||
"heroSubtitle": "Prompts de IA",
|
||||
"heroDescription": "Plataforma de código abierto para gestionar prompts de IA. Auto-hospeda con Docker.",
|
||||
"browsePrompts": "Explorar Prompts",
|
||||
"readyToStart": "¿Listo para comenzar?",
|
||||
"freeAndOpen": "Gratis y de código abierto.",
|
||||
"createAccount": "Crear Cuenta"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"pendingChangeRequests": "Solicitudes de cambio pendientes",
|
||||
"noNotifications": "Sin notificaciones"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Página no encontrada",
|
||||
"unauthorized": "No autorizado",
|
||||
"forbidden": "Prohibido",
|
||||
"serverError": "Error del servidor",
|
||||
"networkError": "Error de red"
|
||||
},
|
||||
"diff": {
|
||||
"tokens": "tokens",
|
||||
"noChanges": "Sin cambios"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Página No Encontrada",
|
||||
"description": "La página que buscas no existe o ha sido movida.",
|
||||
"goHome": "Ir al Inicio",
|
||||
"goBack": "Volver",
|
||||
"helpfulLinks": "Aquí hay algunos enlaces útiles:",
|
||||
"browsePrompts": "Explorar Prompts",
|
||||
"categories": "Categorías",
|
||||
"createPrompt": "Crear Prompt"
|
||||
}
|
||||
}
|
||||
426
messages/ja.json
Normal file
426
messages/ja.json
Normal file
@@ -0,0 +1,426 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "読み込み中...",
|
||||
"error": "エラーが発生しました",
|
||||
"somethingWentWrong": "問題が発生しました",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"create": "作成",
|
||||
"search": "検索",
|
||||
"filter": "フィルター",
|
||||
"sort": "並べ替え",
|
||||
"view": "表示",
|
||||
"copy": "コピー",
|
||||
"copied": "コピーしました!",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"failedToCopy": "コピーに失敗しました",
|
||||
"submit": "送信",
|
||||
"back": "戻る",
|
||||
"next": "次へ",
|
||||
"previous": "前へ",
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"confirm": "確認",
|
||||
"close": "閉じる",
|
||||
"all": "すべて",
|
||||
"none": "なし",
|
||||
"moreLines": "+{count}行"
|
||||
},
|
||||
"nav": {
|
||||
"home": "ホーム",
|
||||
"prompts": "プロンプト",
|
||||
"categories": "カテゴリー",
|
||||
"tags": "タグ",
|
||||
"settings": "設定",
|
||||
"admin": "管理",
|
||||
"profile": "プロフィール",
|
||||
"login": "ログイン",
|
||||
"register": "登録",
|
||||
"logout": "ログアウト"
|
||||
},
|
||||
"auth": {
|
||||
"login": "ログイン",
|
||||
"loginDescription": "続行するには認証情報を入力してください",
|
||||
"register": "登録",
|
||||
"registerDescription": "アカウントを作成して始めましょう",
|
||||
"logout": "ログアウト",
|
||||
"email": "メールアドレス",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワード確認",
|
||||
"username": "ユーザー名",
|
||||
"name": "名前",
|
||||
"forgotPassword": "パスワードをお忘れですか?",
|
||||
"noAccount": "アカウントをお持ちでないですか?",
|
||||
"hasAccount": "すでにアカウントをお持ちですか?",
|
||||
"signInWith": "{provider}でログイン",
|
||||
"orContinueWith": "または以下で続行",
|
||||
"loginSuccess": "ログインしました",
|
||||
"registerSuccess": "登録が完了しました",
|
||||
"logoutSuccess": "ログアウトしました",
|
||||
"invalidCredentials": "メールアドレスまたはパスワードが無効です",
|
||||
"emailTaken": "このメールアドレスは既に使用されています",
|
||||
"usernameTaken": "このユーザー名は既に使用されています",
|
||||
"passwordMismatch": "パスワードが一致しません",
|
||||
"passwordTooShort": "パスワードは6文字以上必要です",
|
||||
"registrationFailed": "登録に失敗しました"
|
||||
},
|
||||
"prompts": {
|
||||
"title": "プロンプト",
|
||||
"create": "プロンプトを作成",
|
||||
"edit": "プロンプトを編集",
|
||||
"delete": "プロンプトを削除",
|
||||
"myPrompts": "マイプロンプト",
|
||||
"publicPrompts": "公開プロンプト",
|
||||
"privatePrompts": "非公開プロンプト",
|
||||
"noPrompts": "プロンプトが見つかりません",
|
||||
"noPromptsDescription": "検索条件やフィルターを調整してみてください。",
|
||||
"promptTitle": "タイトル",
|
||||
"promptContent": "内容",
|
||||
"promptDescription": "説明",
|
||||
"promptType": "タイプ",
|
||||
"promptCategory": "カテゴリー",
|
||||
"promptTags": "タグ",
|
||||
"promptPrivate": "非公開",
|
||||
"promptPublic": "公開",
|
||||
"types": {
|
||||
"text": "テキスト",
|
||||
"image": "画像",
|
||||
"video": "動画",
|
||||
"audio": "音声",
|
||||
"structured": "構造化"
|
||||
},
|
||||
"structuredFormat": "フォーマット",
|
||||
"structuredFormatDescription": "構造化プロンプトのフォーマットを選択",
|
||||
"structuredContentDescription": "ワークフロー、エージェント、またはパイプラインの設定を定義",
|
||||
"versions": "バージョン",
|
||||
"version": "バージョン",
|
||||
"versionsCount": "バージョン",
|
||||
"contributors": "貢献者",
|
||||
"currentVersion": "現在のバージョン",
|
||||
"versionHistory": "バージョン履歴",
|
||||
"noVersions": "バージョン履歴がありません",
|
||||
"compare": "比較",
|
||||
"compareVersions": "バージョンを比較",
|
||||
"compareFrom": "変更前",
|
||||
"compareTo": "変更後",
|
||||
"comparing": "比較中",
|
||||
"selectVersionsToCompare": "比較するバージョンを選択",
|
||||
"compareWithCurrent": "現在と比較",
|
||||
"changeRequests": "変更リクエスト",
|
||||
"createChangeRequest": "変更を提案",
|
||||
"viewCount": "閲覧数",
|
||||
"createdAt": "作成日",
|
||||
"updatedAt": "更新日",
|
||||
"copyPrompt": "プロンプトをコピー",
|
||||
"sharePrompt": "プロンプトを共有",
|
||||
"confirmDelete": "このプロンプトを削除してもよろしいですか?",
|
||||
"promptCreated": "プロンプトを作成しました",
|
||||
"promptUpdated": "プロンプトを更新しました",
|
||||
"run": "実行",
|
||||
"titleRequired": "タイトルは必須です",
|
||||
"contentRequired": "内容は必須です",
|
||||
"titlePlaceholder": "プロンプトのタイトルを入力",
|
||||
"descriptionPlaceholder": "プロンプトの説明(任意)",
|
||||
"contentPlaceholder": "プロンプトの内容を入力...",
|
||||
"selectCategory": "カテゴリーを選択",
|
||||
"noCategory": "なし",
|
||||
"mediaUrl": "メディアURL",
|
||||
"mediaUrlPlaceholder": "https://...",
|
||||
"mediaUrlDescription": "このプロンプトのメディアファイルURLを入力",
|
||||
"privateDescription": "非公開プロンプトはあなただけに表示されます",
|
||||
"update": "更新",
|
||||
"createButton": "作成",
|
||||
"pin": "プロフィールに固定",
|
||||
"unpin": "固定解除",
|
||||
"pinned": "プロフィールに固定しました",
|
||||
"unpinned": "固定を解除しました",
|
||||
"pinFailed": "固定の更新に失敗しました",
|
||||
"pinnedPrompts": "固定済み"
|
||||
},
|
||||
"changeRequests": {
|
||||
"title": "変更リクエスト",
|
||||
"create": "変更リクエストを作成",
|
||||
"createDescription": "このプロンプトの改善や修正を提案",
|
||||
"backToPrompt": "プロンプトに戻る",
|
||||
"currentContent": "現在の内容",
|
||||
"proposedChanges": "提案された変更",
|
||||
"proposedTitle": "提案タイトル",
|
||||
"proposedContent": "提案内容",
|
||||
"proposedContentPlaceholder": "プロンプトへの変更案を入力...",
|
||||
"reason": "変更理由",
|
||||
"reasonPlaceholder": "この変更を提案する理由を説明...",
|
||||
"mustMakeChanges": "少なくとも1つの変更が必要です",
|
||||
"submit": "変更リクエストを送信",
|
||||
"created": "変更リクエストを送信しました",
|
||||
"status": "ステータス",
|
||||
"pending": "審査中",
|
||||
"approved": "承認済み",
|
||||
"rejected": "却下",
|
||||
"approve": "承認",
|
||||
"reject": "却下",
|
||||
"reviewNote": "レビューノート",
|
||||
"reviewNotePlaceholder": "決定についてのメモを追加(任意)...",
|
||||
"reviewActions": "この変更リクエストをレビュー",
|
||||
"optional": "任意",
|
||||
"forPrompt": "対象プロンプト",
|
||||
"titleChange": "タイトルの変更",
|
||||
"contentChanges": "内容の変更",
|
||||
"diffDescription": "赤い行は削除され、緑の行は追加されます",
|
||||
"approvedSuccess": "変更リクエストを承認し、プロンプトを更新しました",
|
||||
"rejectedSuccess": "変更リクエストを却下しました",
|
||||
"reopen": "再開",
|
||||
"reopenedSuccess": "変更リクエストを再開しました",
|
||||
"noRequests": "変更リクエストはありません",
|
||||
"edit": "編集",
|
||||
"preview": "プレビュー",
|
||||
"noChangesYet": "まだ変更はありません",
|
||||
"changesDetected": "変更を検出しました"
|
||||
},
|
||||
"categories": {
|
||||
"title": "カテゴリー",
|
||||
"description": "カテゴリーを閲覧・購読",
|
||||
"create": "カテゴリーを作成",
|
||||
"edit": "カテゴリーを編集",
|
||||
"delete": "カテゴリーを削除",
|
||||
"name": "名前",
|
||||
"parent": "親カテゴリー",
|
||||
"noCategories": "カテゴリーが見つかりません",
|
||||
"noSubcategories": "このカテゴリーにはまだサブカテゴリーがありません",
|
||||
"prompts": "件のプロンプト",
|
||||
"confirmDelete": "このカテゴリーを削除してもよろしいですか?"
|
||||
},
|
||||
"tags": {
|
||||
"title": "タグ",
|
||||
"create": "タグを作成",
|
||||
"edit": "タグを編集",
|
||||
"delete": "タグを削除",
|
||||
"name": "名前",
|
||||
"color": "色",
|
||||
"noTags": "タグが見つかりません",
|
||||
"confirmDelete": "このタグを削除してもよろしいですか?"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"description": "アカウント設定とプロフィールを管理",
|
||||
"profile": "プロフィール",
|
||||
"account": "アカウント",
|
||||
"appearance": "外観",
|
||||
"language": "言語",
|
||||
"theme": "テーマ",
|
||||
"themeLight": "ライト",
|
||||
"themeDark": "ダーク",
|
||||
"themeSystem": "システム",
|
||||
"saveSuccess": "設定を保存しました",
|
||||
"avatar": "アバター",
|
||||
"changeAvatar": "アバターを変更"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理ダッシュボード",
|
||||
"description": "ユーザー、カテゴリー、タグを管理",
|
||||
"stats": {
|
||||
"users": "ユーザー",
|
||||
"prompts": "プロンプト",
|
||||
"categories": "カテゴリー",
|
||||
"tags": "タグ"
|
||||
},
|
||||
"tabs": {
|
||||
"users": "ユーザー",
|
||||
"categories": "カテゴリー",
|
||||
"tags": "タグ"
|
||||
},
|
||||
"users": {
|
||||
"title": "ユーザー管理",
|
||||
"description": "ユーザーアカウントの表示と管理",
|
||||
"user": "ユーザー",
|
||||
"email": "メールアドレス",
|
||||
"role": "役割",
|
||||
"prompts": "プロンプト",
|
||||
"joined": "登録日",
|
||||
"makeAdmin": "管理者にする",
|
||||
"removeAdmin": "管理者を解除",
|
||||
"delete": "削除",
|
||||
"cancel": "キャンセル",
|
||||
"deleted": "ユーザーを削除しました",
|
||||
"deleteFailed": "ユーザーの削除に失敗しました",
|
||||
"roleUpdated": "ユーザーの役割を更新しました",
|
||||
"roleUpdateFailed": "役割の更新に失敗しました",
|
||||
"deleteConfirmTitle": "ユーザーを削除しますか?",
|
||||
"deleteConfirmDescription": "この操作は取り消せません。すべてのユーザーデータが完全に削除されます。"
|
||||
},
|
||||
"categories": {
|
||||
"title": "カテゴリー管理",
|
||||
"description": "プロンプトカテゴリーの作成と管理",
|
||||
"name": "名前",
|
||||
"slug": "スラッグ",
|
||||
"descriptionLabel": "説明",
|
||||
"icon": "アイコン",
|
||||
"parent": "親",
|
||||
"prompts": "プロンプト",
|
||||
"add": "カテゴリーを追加",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"create": "作成",
|
||||
"noCategories": "カテゴリーはまだありません",
|
||||
"created": "カテゴリーを作成しました",
|
||||
"updated": "カテゴリーを更新しました",
|
||||
"deleted": "カテゴリーを削除しました",
|
||||
"saveFailed": "カテゴリーの保存に失敗しました",
|
||||
"deleteFailed": "カテゴリーの削除に失敗しました",
|
||||
"createTitle": "カテゴリーを作成",
|
||||
"createDescription": "プロンプトを整理するための新しいカテゴリーを追加",
|
||||
"editTitle": "カテゴリーを編集",
|
||||
"editDescription": "カテゴリーの詳細を更新",
|
||||
"deleteConfirmTitle": "カテゴリーを削除しますか?",
|
||||
"deleteConfirmDescription": "カテゴリーが削除されます。このカテゴリーのプロンプトは未分類になります。",
|
||||
"parentCategory": "親カテゴリー",
|
||||
"selectParent": "親カテゴリーを選択",
|
||||
"noParent": "なし(ルートカテゴリー)",
|
||||
"parentHelp": "空のままでルートカテゴリーを作成、または親を選択してサブカテゴリーを作成",
|
||||
"rootCategory": "ルート",
|
||||
"subcategories": "サブカテゴリー"
|
||||
},
|
||||
"tags": {
|
||||
"title": "タグ管理",
|
||||
"description": "プロンプトタグの作成と管理",
|
||||
"name": "名前",
|
||||
"slug": "スラッグ",
|
||||
"color": "色",
|
||||
"prompts": "プロンプト",
|
||||
"add": "タグを追加",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"create": "作成",
|
||||
"noTags": "タグはまだありません",
|
||||
"created": "タグを作成しました",
|
||||
"updated": "タグを更新しました",
|
||||
"deleted": "タグを削除しました",
|
||||
"saveFailed": "タグの保存に失敗しました",
|
||||
"deleteFailed": "タグの削除に失敗しました",
|
||||
"createTitle": "タグを作成",
|
||||
"createDescription": "プロンプトにラベルを付けるための新しいタグを追加",
|
||||
"editTitle": "タグを編集",
|
||||
"editDescription": "タグの詳細を更新",
|
||||
"deleteConfirmTitle": "タグを削除しますか?",
|
||||
"deleteConfirmDescription": "すべてのプロンプトからこのタグが削除されます。"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "プロンプトを検索...",
|
||||
"advanced": "詳細検索",
|
||||
"filters": "フィルター",
|
||||
"results": "結果",
|
||||
"noResults": "結果が見つかりません",
|
||||
"searchIn": "検索対象",
|
||||
"sortBy": "並べ替え",
|
||||
"relevance": "関連性",
|
||||
"newest": "新しい順",
|
||||
"oldest": "古い順",
|
||||
"mostUpvoted": "高評価順",
|
||||
"search": "検索",
|
||||
"clear": "クリア",
|
||||
"found": "{count}件見つかりました"
|
||||
},
|
||||
"user": {
|
||||
"profile": "プロフィール",
|
||||
"prompts": "プロンプト",
|
||||
"myPrompts": "マイプロンプト",
|
||||
"allPrompts": "すべてのプロンプト",
|
||||
"joined": "登録日",
|
||||
"noPrompts": "プロンプトはまだありません",
|
||||
"noPromptsOwner": "まだプロンプトを作成していません",
|
||||
"createFirstPrompt": "最初のプロンプトを作成",
|
||||
"upvotesReceived": "獲得した高評価",
|
||||
"editProfile": "プロフィールを編集"
|
||||
},
|
||||
"subscription": {
|
||||
"subscribe": "購読",
|
||||
"unsubscribe": "購読解除",
|
||||
"subscribedTo": "{name}を購読しました",
|
||||
"unsubscribedFrom": "{name}の購読を解除しました",
|
||||
"loginToSubscribe": "購読するにはログインしてください"
|
||||
},
|
||||
"vote": {
|
||||
"loginToVote": "高評価するにはログインしてください",
|
||||
"upvote": "高評価",
|
||||
"upvotes": "高評価"
|
||||
},
|
||||
"version": {
|
||||
"newVersion": "新しいバージョン",
|
||||
"createVersion": "バージョンを作成",
|
||||
"createNewVersion": "新しいバージョンを作成",
|
||||
"updateDescription": "プロンプトの内容を更新し、変更内容を説明するメモを追加してください。",
|
||||
"promptContent": "プロンプトの内容",
|
||||
"changeNote": "変更メモ(任意)",
|
||||
"changeNotePlaceholder": "例:誤字を修正、詳細を追加...",
|
||||
"contentPlaceholder": "更新されたプロンプトの内容を入力...",
|
||||
"contentMustDiffer": "内容は現在のバージョンと異なる必要があります",
|
||||
"versionCreated": "新しいバージョンを作成しました",
|
||||
"deleteVersion": "バージョンを削除",
|
||||
"confirmDeleteVersion": "バージョン{version}を削除してもよろしいですか?この操作は取り消せません。",
|
||||
"versionDeleted": "バージョンを削除しました"
|
||||
},
|
||||
"profile": {
|
||||
"title": "プロフィール",
|
||||
"updateInfo": "プロフィール情報を更新",
|
||||
"avatarUrl": "アバターURL",
|
||||
"displayName": "表示名",
|
||||
"namePlaceholder": "あなたの名前",
|
||||
"username": "ユーザー名",
|
||||
"usernamePlaceholder": "ユーザー名",
|
||||
"profileUrl": "プロフィールURL",
|
||||
"email": "メールアドレス",
|
||||
"emailCannotChange": "メールアドレスは変更できません",
|
||||
"saveChanges": "変更を保存",
|
||||
"profileUpdated": "プロフィールを更新しました",
|
||||
"usernameTaken": "このユーザー名は既に使用されています"
|
||||
},
|
||||
"feed": {
|
||||
"yourFeed": "あなたのフィード",
|
||||
"feedDescription": "購読しているカテゴリーのプロンプト",
|
||||
"browseAll": "すべて見る",
|
||||
"noPromptsInFeed": "フィードにプロンプトがありません",
|
||||
"subscribeToCategories": "カテゴリーを購読してここにプロンプトを表示",
|
||||
"viewAllCategories": "すべてのカテゴリーを見る"
|
||||
},
|
||||
"homepage": {
|
||||
"heroTitle": "収集・整理・共有",
|
||||
"heroSubtitle": "AIプロンプト",
|
||||
"heroDescription": "AIプロンプトを管理するオープンソースプラットフォーム。Dockerでセルフホスト。",
|
||||
"browsePrompts": "プロンプトを見る",
|
||||
"readyToStart": "始める準備はできましたか?",
|
||||
"freeAndOpen": "無料でオープンソース。",
|
||||
"createAccount": "アカウントを作成"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"pendingChangeRequests": "保留中の変更リクエスト",
|
||||
"noNotifications": "通知はありません"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "ページが見つかりません",
|
||||
"unauthorized": "認証されていません",
|
||||
"forbidden": "アクセスが禁止されています",
|
||||
"serverError": "サーバーエラー",
|
||||
"networkError": "ネットワークエラー"
|
||||
},
|
||||
"diff": {
|
||||
"tokens": "トークン",
|
||||
"noChanges": "変更なし"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "ページが見つかりません",
|
||||
"description": "お探しのページは存在しないか、移動された可能性があります。",
|
||||
"goHome": "ホームへ",
|
||||
"goBack": "戻る",
|
||||
"helpfulLinks": "お役立ちリンク:",
|
||||
"browsePrompts": "プロンプトを見る",
|
||||
"categories": "カテゴリー",
|
||||
"createPrompt": "プロンプトを作成"
|
||||
}
|
||||
}
|
||||
471
messages/tr.json
Normal file
471
messages/tr.json
Normal file
@@ -0,0 +1,471 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "Yükleniyor...",
|
||||
"error": "Bir hata oluştu",
|
||||
"somethingWentWrong": "Bir şeyler yanlış gitti",
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
"delete": "Sil",
|
||||
"edit": "Düzenle",
|
||||
"create": "Oluştur",
|
||||
"search": "Ara",
|
||||
"filter": "Filtrele",
|
||||
"sort": "Sırala",
|
||||
"view": "Görüntüle",
|
||||
"copy": "Kopyala",
|
||||
"copied": "Kopyalandı!",
|
||||
"copiedToClipboard": "Panoya kopyalandı",
|
||||
"failedToCopy": "Kopyalama başarısız",
|
||||
"submit": "Gönder",
|
||||
"back": "Geri",
|
||||
"next": "İleri",
|
||||
"previous": "Önceki",
|
||||
"yes": "Evet",
|
||||
"no": "Hayır",
|
||||
"confirm": "Onayla",
|
||||
"close": "Kapat",
|
||||
"all": "Tümü",
|
||||
"none": "Hiçbiri",
|
||||
"moreLines": "+{count} satır daha"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Ana Sayfa",
|
||||
"prompts": "Promptlar",
|
||||
"categories": "Kategoriler",
|
||||
"tags": "Etiketler",
|
||||
"settings": "Ayarlar",
|
||||
"admin": "Yönetim",
|
||||
"profile": "Profil",
|
||||
"login": "Giriş Yap",
|
||||
"register": "Kayıt Ol",
|
||||
"logout": "Çıkış Yap"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Giriş Yap",
|
||||
"loginDescription": "Devam etmek için bilgilerinizi girin",
|
||||
"register": "Kayıt Ol",
|
||||
"registerDescription": "Başlamak için bir hesap oluşturun",
|
||||
"logout": "Çıkış Yap",
|
||||
"email": "E-posta",
|
||||
"password": "Şifre",
|
||||
"confirmPassword": "Şifre Tekrar",
|
||||
"username": "Kullanıcı Adı",
|
||||
"name": "Ad Soyad",
|
||||
"forgotPassword": "Şifremi Unuttum",
|
||||
"noAccount": "Hesabınız yok mu?",
|
||||
"hasAccount": "Zaten hesabınız var mı?",
|
||||
"signInWith": "{provider} ile giriş yap",
|
||||
"orContinueWith": "Veya şununla devam edin",
|
||||
"loginSuccess": "Giriş başarılı",
|
||||
"registerSuccess": "Kayıt başarılı",
|
||||
"logoutSuccess": "Çıkış başarılı",
|
||||
"invalidCredentials": "Geçersiz e-posta veya şifre",
|
||||
"emailTaken": "Bu e-posta zaten kullanılıyor",
|
||||
"usernameTaken": "Bu kullanıcı adı zaten alınmış",
|
||||
"passwordMismatch": "Şifreler eşleşmiyor",
|
||||
"passwordTooShort": "Şifre en az 6 karakter olmalıdır",
|
||||
"registrationFailed": "Kayıt başarısız"
|
||||
},
|
||||
"prompts": {
|
||||
"title": "Promptlar",
|
||||
"create": "Prompt Oluştur",
|
||||
"edit": "Prompt Düzenle",
|
||||
"delete": "Prompt Sil",
|
||||
"myPrompts": "Promptlarım",
|
||||
"publicPrompts": "Herkese Açık Promptlar",
|
||||
"privatePrompts": "Özel Promptlar",
|
||||
"noPrompts": "Prompt bulunamadı",
|
||||
"noPromptsDescription": "Aradığınızı bulmak için arama veya filtre kriterlerinizi değiştirmeyi deneyin.",
|
||||
"noMorePrompts": "Sona ulaştınız",
|
||||
"loading": "Yükleniyor...",
|
||||
"promptTitle": "Başlık",
|
||||
"promptContent": "İçerik",
|
||||
"promptDescription": "Açıklama",
|
||||
"promptType": "Tür",
|
||||
"promptCategory": "Kategori",
|
||||
"promptTags": "Etiketler",
|
||||
"promptPrivate": "Özel",
|
||||
"promptPublic": "Herkese Açık",
|
||||
"types": {
|
||||
"text": "Metin",
|
||||
"image": "Görsel",
|
||||
"video": "Video",
|
||||
"audio": "Ses",
|
||||
"structured": "Yapılandırılmış",
|
||||
"document": "Doküman"
|
||||
},
|
||||
"structuredFormat": "Format",
|
||||
"structuredFormatDescription": "Yapılandırılmış promptunuz için format seçin",
|
||||
"structuredContentDescription": "İş akışı, ajan veya pipeline yapılandırmanızı tanımlayın",
|
||||
"versions": "Versiyonlar",
|
||||
"version": "versiyon",
|
||||
"versionsCount": "versiyon",
|
||||
"contributors": "katkıda bulunan",
|
||||
"currentVersion": "Mevcut Versiyon",
|
||||
"versionHistory": "Versiyon Geçmişi",
|
||||
"noVersions": "Versiyon geçmişi yok",
|
||||
"compare": "Karşılaştır",
|
||||
"compareVersions": "Versiyonları Karşılaştır",
|
||||
"compareFrom": "Kaynak",
|
||||
"compareTo": "Hedef",
|
||||
"comparing": "Karşılaştırılıyor",
|
||||
"selectVersionsToCompare": "Karşılaştırmak için versiyon seçin",
|
||||
"compareWithCurrent": "Güncel ile karşılaştır",
|
||||
"changeRequests": "Değişiklik İstekleri",
|
||||
"createChangeRequest": "Değişiklik Öner",
|
||||
"viewCount": "Görüntülenme",
|
||||
"createdAt": "Oluşturulma",
|
||||
"updatedAt": "Güncellenme",
|
||||
"copyPrompt": "Promptu Kopyala",
|
||||
"sharePrompt": "Promptu Paylaş",
|
||||
"confirmDelete": "Bu promptu silmek istediğinizden emin misiniz?",
|
||||
"promptCreated": "Prompt oluşturuldu",
|
||||
"promptUpdated": "Prompt güncellendi",
|
||||
"run": "Çalıştır",
|
||||
"titleRequired": "Başlık gerekli",
|
||||
"contentRequired": "İçerik gerekli",
|
||||
"titlePlaceholder": "Promptunuz için bir başlık girin",
|
||||
"descriptionPlaceholder": "İsteğe bağlı açıklama",
|
||||
"contentPlaceholder": "Prompt içeriğinizi buraya girin...",
|
||||
"selectCategory": "Kategori seçin",
|
||||
"noCategory": "Yok",
|
||||
"mediaUrl": "Medya URL",
|
||||
"mediaUrlPlaceholder": "https://...",
|
||||
"mediaUrlDescription": "Bu prompt için medya dosyasının URL'sini girin",
|
||||
"privateDescription": "Özel promptlar yalnızca size görünür",
|
||||
"requiresMediaUpload": "Medya Yüklemesi Gerekli",
|
||||
"requiresMediaUploadDescription": "Bu promptu kullanmak için medya dosyası gereklidir",
|
||||
"requiredMediaType": "Medya Türü",
|
||||
"requiredMediaCount": "Dosya Sayısı",
|
||||
"requiresImage": "{count} {count, plural, one {görsel} other {görsel}} gerekli",
|
||||
"requiresVideo": "{count} {count, plural, one {video} other {video}} gerekli",
|
||||
"requiresDocument": "{count} {count, plural, one {doküman} other {doküman}} gerekli",
|
||||
"update": "Güncelle",
|
||||
"createButton": "Oluştur",
|
||||
"pin": "Profile Sabitle",
|
||||
"unpin": "Sabitlemeyi Kaldır",
|
||||
"pinned": "Profile sabitlendi",
|
||||
"unpinned": "Sabitleme kaldırıldı",
|
||||
"pinFailed": "Sabitleme güncellenemedi",
|
||||
"pinnedPrompts": "Sabitlenmiş"
|
||||
},
|
||||
"changeRequests": {
|
||||
"title": "Değişiklik İstekleri",
|
||||
"create": "Değişiklik İsteği Oluştur",
|
||||
"createDescription": "Bu prompt için iyileştirmeler veya düzeltmeler önerin",
|
||||
"backToPrompt": "Prompta dön",
|
||||
"currentContent": "Mevcut içerik",
|
||||
"proposedChanges": "Önerilen Değişiklikler",
|
||||
"proposedTitle": "Önerilen Başlık",
|
||||
"proposedContent": "Önerilen İçerik",
|
||||
"proposedContentPlaceholder": "Prompt için önerdiğiniz değişiklikleri girin...",
|
||||
"reason": "Değişiklik Nedeni",
|
||||
"reasonPlaceholder": "Bu değişiklikleri neden önerdiğinizi açıklayın...",
|
||||
"mustMakeChanges": "En az bir değişiklik yapmalısınız",
|
||||
"submit": "Değişiklik İsteği Gönder",
|
||||
"created": "Değişiklik isteği başarıyla gönderildi",
|
||||
"status": "Durum",
|
||||
"pending": "Beklemede",
|
||||
"approved": "Onaylandı",
|
||||
"rejected": "Reddedildi",
|
||||
"approve": "Onayla",
|
||||
"reject": "Reddet",
|
||||
"reviewNote": "İnceleme Notu",
|
||||
"reviewNotePlaceholder": "Kararınız hakkında bir not ekleyin (isteğe bağlı)...",
|
||||
"reviewActions": "Bu değişiklik isteğini inceleyin",
|
||||
"optional": "isteğe bağlı",
|
||||
"forPrompt": "Prompt için",
|
||||
"titleChange": "Başlık Değişikliği",
|
||||
"contentChanges": "İçerik Değişiklikleri",
|
||||
"diffDescription": "Kırmızı satırlar kaldırılacak, yeşil satırlar eklenecek",
|
||||
"approvedSuccess": "Değişiklik isteği onaylandı ve prompt güncellendi",
|
||||
"rejectedSuccess": "Değişiklik isteği reddedildi",
|
||||
"reopen": "Yeniden Aç",
|
||||
"reopenedSuccess": "Değişiklik isteği yeniden açıldı",
|
||||
"noRequests": "Değişiklik isteği yok",
|
||||
"edit": "Düzenle",
|
||||
"preview": "Önizleme",
|
||||
"noChangesYet": "Henüz değişiklik yok",
|
||||
"changesDetected": "Değişiklikler tespit edildi"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategoriler",
|
||||
"description": "Kategorilere göz atın ve abone olun",
|
||||
"create": "Kategori Oluştur",
|
||||
"edit": "Kategori Düzenle",
|
||||
"delete": "Kategori Sil",
|
||||
"name": "Ad",
|
||||
"parent": "Üst Kategori",
|
||||
"noCategories": "Kategori bulunamadı",
|
||||
"noSubcategories": "Bu kategoride henüz alt kategori yok",
|
||||
"prompts": "prompt",
|
||||
"confirmDelete": "Bu kategoriyi silmek istediğinizden emin misiniz?"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Etiketler",
|
||||
"description": "Etiketlere göre promptları keşfedin",
|
||||
"create": "Etiket Oluştur",
|
||||
"edit": "Etiket Düzenle",
|
||||
"delete": "Etiket Sil",
|
||||
"name": "Ad",
|
||||
"color": "Renk",
|
||||
"noTags": "Etiket bulunamadı",
|
||||
"confirmDelete": "Bu etiketi silmek istediğinizden emin misiniz?",
|
||||
"prompts": "prompt",
|
||||
"allTags": "Tüm Etiketler",
|
||||
"popularTags": "Popüler Etiketler"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"description": "Hesap ayarlarınızı ve profilinizi yönetin",
|
||||
"profile": "Profil",
|
||||
"account": "Hesap",
|
||||
"appearance": "Görünüm",
|
||||
"language": "Dil",
|
||||
"theme": "Tema",
|
||||
"themeLight": "Açık",
|
||||
"themeDark": "Koyu",
|
||||
"themeSystem": "Sistem",
|
||||
"saveSuccess": "Ayarlar başarıyla kaydedildi",
|
||||
"avatar": "Avatar",
|
||||
"changeAvatar": "Avatar Değiştir"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Yönetim Paneli",
|
||||
"description": "Kullanıcıları, kategorileri ve etiketleri yönetin",
|
||||
"stats": {
|
||||
"users": "Kullanıcılar",
|
||||
"prompts": "Promptlar",
|
||||
"categories": "Kategoriler",
|
||||
"tags": "Etiketler"
|
||||
},
|
||||
"tabs": {
|
||||
"users": "Kullanıcılar",
|
||||
"categories": "Kategoriler",
|
||||
"tags": "Etiketler",
|
||||
"webhooks": "Webhook'lar"
|
||||
},
|
||||
"users": {
|
||||
"title": "Kullanıcı Yönetimi",
|
||||
"description": "Kullanıcı hesaplarını görüntüle ve yönet",
|
||||
"user": "Kullanıcı",
|
||||
"email": "E-posta",
|
||||
"role": "Rol",
|
||||
"prompts": "Promptlar",
|
||||
"joined": "Katılım",
|
||||
"makeAdmin": "Admin Yap",
|
||||
"removeAdmin": "Admin Yetkisini Al",
|
||||
"delete": "Sil",
|
||||
"cancel": "İptal",
|
||||
"deleted": "Kullanıcı başarıyla silindi",
|
||||
"deleteFailed": "Kullanıcı silinemedi",
|
||||
"roleUpdated": "Kullanıcı rolü güncellendi",
|
||||
"roleUpdateFailed": "Rol güncellenemedi",
|
||||
"deleteConfirmTitle": "Kullanıcı Silinsin mi?",
|
||||
"deleteConfirmDescription": "Bu işlem geri alınamaz. Tüm kullanıcı verileri kalıcı olarak silinecektir."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategori Yönetimi",
|
||||
"description": "Prompt kategorilerini oluştur ve yönet",
|
||||
"name": "Ad",
|
||||
"slug": "Slug",
|
||||
"descriptionLabel": "Açıklama",
|
||||
"icon": "İkon",
|
||||
"parent": "Üst Kategori",
|
||||
"prompts": "Promptlar",
|
||||
"add": "Kategori Ekle",
|
||||
"edit": "Düzenle",
|
||||
"delete": "Sil",
|
||||
"cancel": "İptal",
|
||||
"save": "Kaydet",
|
||||
"create": "Oluştur",
|
||||
"noCategories": "Henüz kategori yok",
|
||||
"created": "Kategori başarıyla oluşturuldu",
|
||||
"updated": "Kategori başarıyla güncellendi",
|
||||
"deleted": "Kategori başarıyla silindi",
|
||||
"saveFailed": "Kategori kaydedilemedi",
|
||||
"deleteFailed": "Kategori silinemedi",
|
||||
"createTitle": "Kategori Oluştur",
|
||||
"createDescription": "Promptları düzenlemek için yeni bir kategori ekleyin",
|
||||
"editTitle": "Kategori Düzenle",
|
||||
"editDescription": "Kategori detaylarını güncelleyin",
|
||||
"deleteConfirmTitle": "Kategori Silinsin mi?",
|
||||
"deleteConfirmDescription": "Bu kategori silinecektir. Bu kategorideki promptlar kategorisiz kalacaktır.",
|
||||
"parentCategory": "Üst Kategori",
|
||||
"selectParent": "Üst kategori seçin",
|
||||
"noParent": "Yok (Ana Kategori)",
|
||||
"parentHelp": "Ana kategori oluşturmak için boş bırakın veya alt kategori oluşturmak için bir üst kategori seçin",
|
||||
"rootCategory": "Ana",
|
||||
"subcategories": "alt kategori"
|
||||
},
|
||||
"tags": {
|
||||
"title": "Etiket Yönetimi",
|
||||
"description": "Prompt etiketlerini oluştur ve yönet",
|
||||
"name": "Ad",
|
||||
"slug": "Slug",
|
||||
"color": "Renk",
|
||||
"prompts": "Promptlar",
|
||||
"add": "Etiket Ekle",
|
||||
"edit": "Düzenle",
|
||||
"delete": "Sil",
|
||||
"cancel": "İptal",
|
||||
"save": "Kaydet",
|
||||
"create": "Oluştur",
|
||||
"noTags": "Henüz etiket yok",
|
||||
"created": "Etiket başarıyla oluşturuldu",
|
||||
"updated": "Etiket başarıyla güncellendi",
|
||||
"deleted": "Etiket başarıyla silindi",
|
||||
"saveFailed": "Etiket kaydedilemedi",
|
||||
"deleteFailed": "Etiket silinemedi",
|
||||
"createTitle": "Etiket Oluştur",
|
||||
"createDescription": "Promptları etiketlemek için yeni bir etiket ekleyin",
|
||||
"editTitle": "Etiket Düzenle",
|
||||
"editDescription": "Etiket detaylarını güncelleyin",
|
||||
"deleteConfirmTitle": "Etiket Silinsin mi?",
|
||||
"deleteConfirmDescription": "Bu etiket tüm promptlardan kaldırılacaktır."
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhook Yönetimi",
|
||||
"description": "Olaylar gerçekleştiğinde bildirim almak için webhook'ları yapılandırın",
|
||||
"name": "Ad",
|
||||
"url": "Webhook URL",
|
||||
"method": "HTTP Metodu",
|
||||
"headers": "HTTP Başlıkları",
|
||||
"events": "Olaylar",
|
||||
"payload": "JSON Payload",
|
||||
"payloadHelp": "{{PROMPT_TITLE}} gibi yer tutucular kullanın",
|
||||
"placeholders": "Kullanılabilir Yer Tutucular",
|
||||
"status": "Durum",
|
||||
"enabled": "Aktif",
|
||||
"add": "Webhook Ekle",
|
||||
"edit": "Düzenle",
|
||||
"delete": "Sil",
|
||||
"cancel": "İptal",
|
||||
"save": "Kaydet",
|
||||
"create": "Oluştur",
|
||||
"empty": "Yapılandırılmış webhook yok",
|
||||
"addTitle": "Webhook Ekle",
|
||||
"addDescription": "Yeni bir webhook uç noktası yapılandırın",
|
||||
"editTitle": "Webhook Düzenle",
|
||||
"editDescription": "Webhook yapılandırmasını güncelleyin",
|
||||
"deleteConfirm": "Bu webhook'u silmek istediğinizden emin misiniz?",
|
||||
"useSlackPreset": "Slack Şablonu Kullan",
|
||||
"test": "Test",
|
||||
"testSuccess": "Webhook testi başarılı!",
|
||||
"testFailed": "Webhook testi başarısız"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Prompt ara...",
|
||||
"advanced": "Gelişmiş Arama",
|
||||
"filters": "Filtreler",
|
||||
"results": "Sonuçlar",
|
||||
"noResults": "Sonuç bulunamadı",
|
||||
"searchIn": "Şurada ara",
|
||||
"sortBy": "Sırala",
|
||||
"relevance": "İlgililik",
|
||||
"newest": "En Yeni",
|
||||
"oldest": "En Eski",
|
||||
"mostUpvoted": "En Çok Beğenilen",
|
||||
"search": "Ara",
|
||||
"clear": "Temizle",
|
||||
"found": "{count} bulundu"
|
||||
},
|
||||
"user": {
|
||||
"profile": "Profil",
|
||||
"prompts": "Promptlar",
|
||||
"myPrompts": "Promptlarım",
|
||||
"allPrompts": "Tüm Promptlar",
|
||||
"joined": "Katılım",
|
||||
"noPrompts": "Henüz prompt yok",
|
||||
"noPromptsOwner": "Henüz prompt oluşturmadınız",
|
||||
"createFirstPrompt": "İlk promptunuzu oluşturun",
|
||||
"upvotesReceived": "beğeni aldı",
|
||||
"editProfile": "Profili Düzenle"
|
||||
},
|
||||
"subscription": {
|
||||
"subscribe": "Abone Ol",
|
||||
"unsubscribe": "Abonelikten Çık",
|
||||
"subscribedTo": "{name} kategorisine abone oldunuz",
|
||||
"unsubscribedFrom": "{name} aboneliğinden çıktınız",
|
||||
"loginToSubscribe": "Abone olmak için giriş yapın"
|
||||
},
|
||||
"vote": {
|
||||
"loginToVote": "Oy vermek için giriş yapın",
|
||||
"upvote": "beğeni",
|
||||
"upvotes": "beğeni"
|
||||
},
|
||||
"version": {
|
||||
"newVersion": "Yeni Sürüm",
|
||||
"createVersion": "Sürüm Oluştur",
|
||||
"createNewVersion": "Yeni Sürüm Oluştur",
|
||||
"updateDescription": "Prompt içeriğini güncelleyin ve değişikliklerinizi açıklayan bir not ekleyin.",
|
||||
"promptContent": "Prompt İçeriği",
|
||||
"changeNote": "Değişiklik Notu (isteğe bağlı)",
|
||||
"changeNotePlaceholder": "örn., Yazım hatası düzeltildi, Daha fazla bağlam eklendi...",
|
||||
"contentPlaceholder": "Güncellenmiş prompt içeriğini girin...",
|
||||
"contentMustDiffer": "İçerik mevcut versiyondan farklı olmalıdır",
|
||||
"versionCreated": "Yeni versiyon oluşturuldu",
|
||||
"deleteVersion": "Versiyonu Sil",
|
||||
"confirmDeleteVersion": "Versiyon {version}'ü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"versionDeleted": "Versiyon başarıyla silindi"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"updateInfo": "Profil bilgilerinizi güncelleyin",
|
||||
"avatarUrl": "Avatar URL",
|
||||
"displayName": "Görünen Ad",
|
||||
"namePlaceholder": "Adınız",
|
||||
"username": "Kullanıcı Adı",
|
||||
"usernamePlaceholder": "kullaniciadi",
|
||||
"profileUrl": "Profil URL'niz",
|
||||
"email": "E-posta",
|
||||
"emailCannotChange": "E-posta değiştirilemez",
|
||||
"saveChanges": "Değişiklikleri Kaydet",
|
||||
"profileUpdated": "Profil başarıyla güncellendi",
|
||||
"usernameTaken": "Bu kullanıcı adı zaten alınmış"
|
||||
},
|
||||
"feed": {
|
||||
"yourFeed": "Akışınız",
|
||||
"feedDescription": "Abone olduğunuz kategorilerden promptlar",
|
||||
"browseAll": "Tümüne Göz At",
|
||||
"noPromptsInFeed": "Akışınızda prompt yok",
|
||||
"subscribeToCategories": "Burada promptları görmek için kategorilere abone olun",
|
||||
"viewAllCategories": "Tüm Kategorileri Gör"
|
||||
},
|
||||
"homepage": {
|
||||
"heroTitle": "Topla, Düzenle ve Paylaş",
|
||||
"heroSubtitle": "AI Promptları",
|
||||
"heroDescription": "AI promptlarını yönetmek için açık kaynak platform. Docker ile kendi sunucunuzda çalıştırın.",
|
||||
"browsePrompts": "Promptlara Göz At",
|
||||
"readyToStart": "Başlamaya hazır mısınız?",
|
||||
"freeAndOpen": "Ücretsiz ve açık kaynak.",
|
||||
"createAccount": "Hesap Oluştur"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Bildirimler",
|
||||
"pendingChangeRequests": "Bekleyen değişiklik istekleri",
|
||||
"noNotifications": "Bildirim yok"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Sayfa bulunamadı",
|
||||
"unauthorized": "Yetkisiz erişim",
|
||||
"forbidden": "Erişim engellendi",
|
||||
"serverError": "Sunucu hatası",
|
||||
"networkError": "Ağ hatası"
|
||||
},
|
||||
"diff": {
|
||||
"tokens": "token",
|
||||
"noChanges": "Değişiklik yok"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Sayfa Bulunamadı",
|
||||
"description": "Aradığınız sayfa mevcut değil veya taşınmış olabilir.",
|
||||
"goHome": "Ana Sayfaya Git",
|
||||
"goBack": "Geri Dön",
|
||||
"helpfulLinks": "İşte bazı faydalı bağlantılar:",
|
||||
"browsePrompts": "Promptlara Göz At",
|
||||
"categories": "Kategoriler",
|
||||
"createPrompt": "Prompt Oluştur"
|
||||
}
|
||||
}
|
||||
426
messages/zh.json
Normal file
426
messages/zh.json
Normal file
@@ -0,0 +1,426 @@
|
||||
{
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"error": "发生错误",
|
||||
"somethingWentWrong": "出了点问题",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"create": "创建",
|
||||
"search": "搜索",
|
||||
"filter": "筛选",
|
||||
"sort": "排序",
|
||||
"view": "查看",
|
||||
"copy": "复制",
|
||||
"copied": "已复制!",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"failedToCopy": "复制失败",
|
||||
"submit": "提交",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"confirm": "确认",
|
||||
"close": "关闭",
|
||||
"all": "全部",
|
||||
"none": "无",
|
||||
"moreLines": "+{count}行"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"prompts": "提示词",
|
||||
"categories": "分类",
|
||||
"tags": "标签",
|
||||
"settings": "设置",
|
||||
"admin": "管理",
|
||||
"profile": "个人资料",
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"loginDescription": "输入您的凭据以继续",
|
||||
"register": "注册",
|
||||
"registerDescription": "创建账户以开始使用",
|
||||
"logout": "退出登录",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"username": "用户名",
|
||||
"name": "姓名",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"noAccount": "没有账户?",
|
||||
"hasAccount": "已有账户?",
|
||||
"signInWith": "使用 {provider} 登录",
|
||||
"orContinueWith": "或继续使用",
|
||||
"loginSuccess": "登录成功",
|
||||
"registerSuccess": "注册成功",
|
||||
"logoutSuccess": "已成功退出登录",
|
||||
"invalidCredentials": "邮箱或密码无效",
|
||||
"emailTaken": "该邮箱已被使用",
|
||||
"usernameTaken": "该用户名已被使用",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"passwordTooShort": "密码至少需要6个字符",
|
||||
"registrationFailed": "注册失败"
|
||||
},
|
||||
"prompts": {
|
||||
"title": "提示词",
|
||||
"create": "创建提示词",
|
||||
"edit": "编辑提示词",
|
||||
"delete": "删除提示词",
|
||||
"myPrompts": "我的提示词",
|
||||
"publicPrompts": "公开提示词",
|
||||
"privatePrompts": "私有提示词",
|
||||
"noPrompts": "未找到提示词",
|
||||
"noPromptsDescription": "尝试调整搜索或筛选条件以找到您需要的内容。",
|
||||
"promptTitle": "标题",
|
||||
"promptContent": "内容",
|
||||
"promptDescription": "描述",
|
||||
"promptType": "类型",
|
||||
"promptCategory": "分类",
|
||||
"promptTags": "标签",
|
||||
"promptPrivate": "私有",
|
||||
"promptPublic": "公开",
|
||||
"types": {
|
||||
"text": "文本",
|
||||
"image": "图片",
|
||||
"video": "视频",
|
||||
"audio": "音频",
|
||||
"structured": "结构化"
|
||||
},
|
||||
"structuredFormat": "格式",
|
||||
"structuredFormatDescription": "选择结构化提示词的格式",
|
||||
"structuredContentDescription": "定义你的工作流、代理或管道配置",
|
||||
"versions": "版本",
|
||||
"version": "个版本",
|
||||
"versionsCount": "个版本",
|
||||
"contributors": "位贡献者",
|
||||
"currentVersion": "当前版本",
|
||||
"versionHistory": "版本历史",
|
||||
"noVersions": "无版本历史",
|
||||
"compare": "对比",
|
||||
"compareVersions": "对比版本",
|
||||
"compareFrom": "从",
|
||||
"compareTo": "到",
|
||||
"comparing": "对比中",
|
||||
"selectVersionsToCompare": "选择要对比的版本",
|
||||
"compareWithCurrent": "与当前版本对比",
|
||||
"changeRequests": "变更请求",
|
||||
"createChangeRequest": "提议变更",
|
||||
"viewCount": "浏览量",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间",
|
||||
"copyPrompt": "复制提示词",
|
||||
"sharePrompt": "分享提示词",
|
||||
"confirmDelete": "确定要删除此提示词吗?",
|
||||
"promptCreated": "提示词已创建",
|
||||
"promptUpdated": "提示词已更新",
|
||||
"run": "运行",
|
||||
"titleRequired": "标题为必填项",
|
||||
"contentRequired": "内容为必填项",
|
||||
"titlePlaceholder": "输入提示词标题",
|
||||
"descriptionPlaceholder": "可选的提示词描述",
|
||||
"contentPlaceholder": "在此输入提示词内容...",
|
||||
"selectCategory": "选择分类",
|
||||
"noCategory": "无",
|
||||
"mediaUrl": "媒体链接",
|
||||
"mediaUrlPlaceholder": "https://...",
|
||||
"mediaUrlDescription": "输入此提示词的媒体文件链接",
|
||||
"privateDescription": "私有提示词仅对您可见",
|
||||
"update": "更新",
|
||||
"createButton": "创建",
|
||||
"pin": "置顶到个人资料",
|
||||
"unpin": "取消置顶",
|
||||
"pinned": "已置顶到个人资料",
|
||||
"unpinned": "已从个人资料取消置顶",
|
||||
"pinFailed": "置顶更新失败",
|
||||
"pinnedPrompts": "已置顶"
|
||||
},
|
||||
"changeRequests": {
|
||||
"title": "变更请求",
|
||||
"create": "创建变更请求",
|
||||
"createDescription": "为此提示词建议改进或修复",
|
||||
"backToPrompt": "返回提示词",
|
||||
"currentContent": "当前内容",
|
||||
"proposedChanges": "建议的变更",
|
||||
"proposedTitle": "建议的标题",
|
||||
"proposedContent": "建议的内容",
|
||||
"proposedContentPlaceholder": "输入您对提示词的建议变更...",
|
||||
"reason": "变更原因",
|
||||
"reasonPlaceholder": "解释您为何建议这些变更...",
|
||||
"mustMakeChanges": "您必须至少做出一项变更",
|
||||
"submit": "提交变更请求",
|
||||
"created": "变更请求提交成功",
|
||||
"status": "状态",
|
||||
"pending": "待审核",
|
||||
"approved": "已批准",
|
||||
"rejected": "已拒绝",
|
||||
"approve": "批准",
|
||||
"reject": "拒绝",
|
||||
"reviewNote": "审核备注",
|
||||
"reviewNotePlaceholder": "添加关于您决定的备注(可选)...",
|
||||
"reviewActions": "审核此变更请求",
|
||||
"optional": "可选",
|
||||
"forPrompt": "针对提示词",
|
||||
"titleChange": "标题变更",
|
||||
"contentChanges": "内容变更",
|
||||
"diffDescription": "红色行将被删除,绿色行将被添加",
|
||||
"approvedSuccess": "变更请求已批准,提示词已更新",
|
||||
"rejectedSuccess": "变更请求已拒绝",
|
||||
"reopen": "重新开启",
|
||||
"reopenedSuccess": "变更请求已重新开启",
|
||||
"noRequests": "暂无变更请求",
|
||||
"edit": "编辑",
|
||||
"preview": "预览",
|
||||
"noChangesYet": "暂无变更",
|
||||
"changesDetected": "检测到变更"
|
||||
},
|
||||
"categories": {
|
||||
"title": "分类",
|
||||
"description": "浏览和订阅分类",
|
||||
"create": "创建分类",
|
||||
"edit": "编辑分类",
|
||||
"delete": "删除分类",
|
||||
"name": "名称",
|
||||
"parent": "父分类",
|
||||
"noCategories": "未找到分类",
|
||||
"noSubcategories": "此分类下暂无子分类",
|
||||
"prompts": "个提示词",
|
||||
"confirmDelete": "确定要删除此分类吗?"
|
||||
},
|
||||
"tags": {
|
||||
"title": "标签",
|
||||
"create": "创建标签",
|
||||
"edit": "编辑标签",
|
||||
"delete": "删除标签",
|
||||
"name": "名称",
|
||||
"color": "颜色",
|
||||
"noTags": "未找到标签",
|
||||
"confirmDelete": "确定要删除此标签吗?"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"description": "管理您的账户设置和个人资料",
|
||||
"profile": "个人资料",
|
||||
"account": "账户",
|
||||
"appearance": "外观",
|
||||
"language": "语言",
|
||||
"theme": "主题",
|
||||
"themeLight": "浅色",
|
||||
"themeDark": "深色",
|
||||
"themeSystem": "跟随系统",
|
||||
"saveSuccess": "设置保存成功",
|
||||
"avatar": "头像",
|
||||
"changeAvatar": "更换头像"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理面板",
|
||||
"description": "管理用户、分类和标签",
|
||||
"stats": {
|
||||
"users": "用户",
|
||||
"prompts": "提示词",
|
||||
"categories": "分类",
|
||||
"tags": "标签"
|
||||
},
|
||||
"tabs": {
|
||||
"users": "用户",
|
||||
"categories": "分类",
|
||||
"tags": "标签"
|
||||
},
|
||||
"users": {
|
||||
"title": "用户管理",
|
||||
"description": "查看和管理用户账户",
|
||||
"user": "用户",
|
||||
"email": "邮箱",
|
||||
"role": "角色",
|
||||
"prompts": "提示词",
|
||||
"joined": "加入时间",
|
||||
"makeAdmin": "设为管理员",
|
||||
"removeAdmin": "取消管理员",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"deleted": "用户删除成功",
|
||||
"deleteFailed": "删除用户失败",
|
||||
"roleUpdated": "用户角色已更新",
|
||||
"roleUpdateFailed": "更新角色失败",
|
||||
"deleteConfirmTitle": "删除用户?",
|
||||
"deleteConfirmDescription": "此操作无法撤销。所有用户数据将被永久删除。"
|
||||
},
|
||||
"categories": {
|
||||
"title": "分类管理",
|
||||
"description": "创建和管理提示词分类",
|
||||
"name": "名称",
|
||||
"slug": "别名",
|
||||
"descriptionLabel": "描述",
|
||||
"icon": "图标",
|
||||
"parent": "父级",
|
||||
"prompts": "提示词",
|
||||
"add": "添加分类",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"create": "创建",
|
||||
"noCategories": "暂无分类",
|
||||
"created": "分类创建成功",
|
||||
"updated": "分类更新成功",
|
||||
"deleted": "分类删除成功",
|
||||
"saveFailed": "保存分类失败",
|
||||
"deleteFailed": "删除分类失败",
|
||||
"createTitle": "创建分类",
|
||||
"createDescription": "添加新分类以组织提示词",
|
||||
"editTitle": "编辑分类",
|
||||
"editDescription": "更新分类详情",
|
||||
"deleteConfirmTitle": "删除分类?",
|
||||
"deleteConfirmDescription": "这将删除该分类。此分类下的提示词将变为未分类。",
|
||||
"parentCategory": "父分类",
|
||||
"selectParent": "选择父分类",
|
||||
"noParent": "无(根分类)",
|
||||
"parentHelp": "留空以创建根分类,或选择父级以创建子分类",
|
||||
"rootCategory": "根",
|
||||
"subcategories": "个子分类"
|
||||
},
|
||||
"tags": {
|
||||
"title": "标签管理",
|
||||
"description": "创建和管理提示词标签",
|
||||
"name": "名称",
|
||||
"slug": "别名",
|
||||
"color": "颜色",
|
||||
"prompts": "提示词",
|
||||
"add": "添加标签",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"create": "创建",
|
||||
"noTags": "暂无标签",
|
||||
"created": "标签创建成功",
|
||||
"updated": "标签更新成功",
|
||||
"deleted": "标签删除成功",
|
||||
"saveFailed": "保存标签失败",
|
||||
"deleteFailed": "删除标签失败",
|
||||
"createTitle": "创建标签",
|
||||
"createDescription": "添加新标签以标记提示词",
|
||||
"editTitle": "编辑标签",
|
||||
"editDescription": "更新标签详情",
|
||||
"deleteConfirmTitle": "删除标签?",
|
||||
"deleteConfirmDescription": "这将从所有提示词中移除该标签。"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索提示词...",
|
||||
"advanced": "高级搜索",
|
||||
"filters": "筛选",
|
||||
"results": "结果",
|
||||
"noResults": "未找到结果",
|
||||
"searchIn": "搜索范围",
|
||||
"sortBy": "排序方式",
|
||||
"relevance": "相关性",
|
||||
"newest": "最新",
|
||||
"oldest": "最早",
|
||||
"mostUpvoted": "最多点赞",
|
||||
"search": "搜索",
|
||||
"clear": "清除",
|
||||
"found": "找到 {count} 个"
|
||||
},
|
||||
"user": {
|
||||
"profile": "个人资料",
|
||||
"prompts": "提示词",
|
||||
"myPrompts": "我的提示词",
|
||||
"allPrompts": "全部提示词",
|
||||
"joined": "加入时间",
|
||||
"noPrompts": "暂无提示词",
|
||||
"noPromptsOwner": "您还没有创建任何提示词",
|
||||
"createFirstPrompt": "创建您的第一个提示词",
|
||||
"upvotesReceived": "收到的点赞",
|
||||
"editProfile": "编辑资料"
|
||||
},
|
||||
"subscription": {
|
||||
"subscribe": "订阅",
|
||||
"unsubscribe": "取消订阅",
|
||||
"subscribedTo": "已订阅 {name}",
|
||||
"unsubscribedFrom": "已取消订阅 {name}",
|
||||
"loginToSubscribe": "请登录后订阅"
|
||||
},
|
||||
"vote": {
|
||||
"loginToVote": "请登录后点赞",
|
||||
"upvote": "赞",
|
||||
"upvotes": "赞"
|
||||
},
|
||||
"version": {
|
||||
"newVersion": "新版本",
|
||||
"createVersion": "创建版本",
|
||||
"createNewVersion": "创建新版本",
|
||||
"updateDescription": "更新提示词内容并添加描述您变更的备注。",
|
||||
"promptContent": "提示词内容",
|
||||
"changeNote": "变更备注(可选)",
|
||||
"changeNotePlaceholder": "例如:修复错别字,添加更多上下文...",
|
||||
"contentPlaceholder": "输入更新后的提示词内容...",
|
||||
"contentMustDiffer": "内容必须与当前版本不同",
|
||||
"versionCreated": "新版本已创建",
|
||||
"deleteVersion": "删除版本",
|
||||
"confirmDeleteVersion": "确定要删除版本 {version} 吗?此操作无法撤销。",
|
||||
"versionDeleted": "版本删除成功"
|
||||
},
|
||||
"profile": {
|
||||
"title": "个人资料",
|
||||
"updateInfo": "更新您的个人资料信息",
|
||||
"avatarUrl": "头像链接",
|
||||
"displayName": "显示名称",
|
||||
"namePlaceholder": "您的名字",
|
||||
"username": "用户名",
|
||||
"usernamePlaceholder": "用户名",
|
||||
"profileUrl": "您的主页链接",
|
||||
"email": "邮箱",
|
||||
"emailCannotChange": "邮箱无法更改",
|
||||
"saveChanges": "保存更改",
|
||||
"profileUpdated": "个人资料更新成功",
|
||||
"usernameTaken": "该用户名已被使用"
|
||||
},
|
||||
"feed": {
|
||||
"yourFeed": "您的订阅",
|
||||
"feedDescription": "来自您订阅分类的提示词",
|
||||
"browseAll": "浏览全部",
|
||||
"noPromptsInFeed": "您的订阅中暂无提示词",
|
||||
"subscribeToCategories": "订阅分类以在此查看提示词",
|
||||
"viewAllCategories": "查看所有分类"
|
||||
},
|
||||
"homepage": {
|
||||
"heroTitle": "收集、整理和分享",
|
||||
"heroSubtitle": "AI 提示词",
|
||||
"heroDescription": "管理 AI 提示词的开源平台。使用 Docker 自托管。",
|
||||
"browsePrompts": "浏览提示词",
|
||||
"readyToStart": "准备好开始了吗?",
|
||||
"freeAndOpen": "免费且开源。",
|
||||
"createAccount": "创建账户"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"pendingChangeRequests": "待处理的变更请求",
|
||||
"noNotifications": "暂无通知"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "页面未找到",
|
||||
"unauthorized": "未授权",
|
||||
"forbidden": "禁止访问",
|
||||
"serverError": "服务器错误",
|
||||
"networkError": "网络错误"
|
||||
},
|
||||
"diff": {
|
||||
"tokens": "token",
|
||||
"noChanges": "无变更"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "页面未找到",
|
||||
"description": "您要查找的页面不存在或已被移动。",
|
||||
"goHome": "返回首页",
|
||||
"goBack": "返回上一页",
|
||||
"helpfulLinks": "以下是一些有用的链接:",
|
||||
"browsePrompts": "浏览提示词",
|
||||
"categories": "分类",
|
||||
"createPrompt": "创建提示词"
|
||||
}
|
||||
}
|
||||
28
next.config.ts
Normal file
28
next.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactCompiler: true,
|
||||
// Enable standalone output for Docker
|
||||
output: "standalone",
|
||||
// Experimental features
|
||||
experimental: {
|
||||
// Enable server actions
|
||||
serverActions: {
|
||||
bodySizeLimit: "2mb",
|
||||
},
|
||||
},
|
||||
// Image optimization
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
10393
package-lock.json
generated
Normal file
10393
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
package.json
Normal file
71
package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "prompts.chat-v2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "prisma db seed",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "16.0.7",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-intl": "^4.5.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "npx tsx prisma/seed.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.7",
|
||||
"prisma": "^6.19.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
16
prisma.config.ts
Normal file
16
prisma.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
// This file was generated by Prisma and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
engine: "classic",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
228
prisma/migrations/20251208165032/migration.sql
Normal file
228
prisma/migrations/20251208165032/migration.sql
Normal file
@@ -0,0 +1,228 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PromptType" AS ENUM ('TEXT', 'IMAGE', 'VIDEO', 'AUDIO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChangeRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"password" TEXT,
|
||||
"avatar" TEXT,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'USER',
|
||||
"locale" TEXT NOT NULL DEFAULT 'en',
|
||||
"emailVerified" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
|
||||
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "prompts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"type" "PromptType" NOT NULL DEFAULT 'TEXT',
|
||||
"isPrivate" BOOLEAN NOT NULL DEFAULT false,
|
||||
"mediaUrl" TEXT,
|
||||
"viewCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"categoryId" TEXT,
|
||||
|
||||
CONSTRAINT "prompts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "prompt_versions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"changeNote" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"promptId" TEXT NOT NULL,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "prompt_versions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "change_requests" (
|
||||
"id" TEXT NOT NULL,
|
||||
"proposedContent" TEXT NOT NULL,
|
||||
"proposedTitle" TEXT,
|
||||
"reason" TEXT,
|
||||
"status" "ChangeRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"reviewNote" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"promptId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "change_requests_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"icon" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"parentId" TEXT,
|
||||
|
||||
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tags" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT '#6366f1',
|
||||
|
||||
CONSTRAINT "tags_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "prompt_tags" (
|
||||
"promptId" TEXT NOT NULL,
|
||||
"tagId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "prompt_tags_pkey" PRIMARY KEY ("promptId","tagId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "accounts_provider_providerAccountId_key" ON "accounts"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_sessionToken_key" ON "sessions"("sessionToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_token_key" ON "verification_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "prompts_authorId_idx" ON "prompts"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "prompts_categoryId_idx" ON "prompts"("categoryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "prompts_type_idx" ON "prompts"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "prompts_isPrivate_idx" ON "prompts"("isPrivate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "prompt_versions_promptId_idx" ON "prompt_versions"("promptId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "prompt_versions_promptId_version_key" ON "prompt_versions"("promptId", "version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "change_requests_promptId_idx" ON "change_requests"("promptId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "change_requests_authorId_idx" ON "change_requests"("authorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "change_requests_status_idx" ON "change_requests"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "categories_slug_key" ON "categories"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "categories_parentId_idx" ON "categories"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_slug_key" ON "tags"("slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "prompts" ADD CONSTRAINT "prompts_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "prompts" ADD CONSTRAINT "prompts_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "prompt_versions" ADD CONSTRAINT "prompt_versions_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "prompt_versions" ADD CONSTRAINT "prompt_versions_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "change_requests" ADD CONSTRAINT "change_requests_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "change_requests" ADD CONSTRAINT "change_requests_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "prompt_tags" ADD CONSTRAINT "prompt_tags_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "prompt_tags" ADD CONSTRAINT "prompt_tags_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
20
prisma/migrations/20251208185808_init/migration.sql
Normal file
20
prisma/migrations/20251208185808_init/migration.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "category_subscriptions" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"categoryId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "category_subscriptions_pkey" PRIMARY KEY ("userId","categoryId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "category_subscriptions_userId_idx" ON "category_subscriptions"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "category_subscriptions_categoryId_idx" ON "category_subscriptions"("categoryId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "category_subscriptions" ADD CONSTRAINT "category_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "category_subscriptions" ADD CONSTRAINT "category_subscriptions_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
313
prisma/schema.prisma
Normal file
313
prisma/schema.prisma
Normal file
@@ -0,0 +1,313 @@
|
||||
// Prisma schema for prompts.chat
|
||||
// https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User & Authentication
|
||||
// ============================================
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
name String?
|
||||
password String? // For credentials auth
|
||||
avatar String?
|
||||
role UserRole @default(USER)
|
||||
locale String @default("en")
|
||||
emailVerified DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
prompts Prompt[] @relation("PromptAuthor")
|
||||
contributions Prompt[] @relation("PromptContributors")
|
||||
promptVersions PromptVersion[]
|
||||
changeRequests ChangeRequest[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
subscriptions CategorySubscription[]
|
||||
votes PromptVote[]
|
||||
pinnedPrompts PinnedPrompt[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Prompts & Content
|
||||
// ============================================
|
||||
|
||||
enum PromptType {
|
||||
TEXT
|
||||
IMAGE
|
||||
VIDEO
|
||||
AUDIO
|
||||
STRUCTURED
|
||||
}
|
||||
|
||||
enum StructuredFormat {
|
||||
JSON
|
||||
YAML
|
||||
}
|
||||
|
||||
enum ChangeRequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum RequiredMediaType {
|
||||
IMAGE
|
||||
VIDEO
|
||||
DOCUMENT
|
||||
}
|
||||
|
||||
model Prompt {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
content String @db.Text
|
||||
type PromptType @default(TEXT)
|
||||
structuredFormat StructuredFormat? // For STRUCTURED type: JSON or YAML
|
||||
isPrivate Boolean @default(false)
|
||||
mediaUrl String? // For non-text prompts
|
||||
|
||||
// Media requirements (user needs to upload media to use this prompt)
|
||||
requiresMediaUpload Boolean @default(false)
|
||||
requiredMediaType RequiredMediaType?
|
||||
requiredMediaCount Int?
|
||||
|
||||
// Metadata
|
||||
viewCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
authorId String
|
||||
author User @relation("PromptAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
|
||||
versions PromptVersion[]
|
||||
changeRequests ChangeRequest[]
|
||||
tags PromptTag[]
|
||||
votes PromptVote[]
|
||||
contributors User[] @relation("PromptContributors")
|
||||
pinnedBy PinnedPrompt[]
|
||||
|
||||
@@index([authorId])
|
||||
@@index([categoryId])
|
||||
@@index([type])
|
||||
@@index([isPrivate])
|
||||
@@map("prompts")
|
||||
}
|
||||
|
||||
model PromptVersion {
|
||||
id String @id @default(cuid())
|
||||
version Int
|
||||
content String @db.Text
|
||||
changeNote String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
promptId String
|
||||
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
createdBy String
|
||||
author User @relation(fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([promptId, version])
|
||||
@@index([promptId])
|
||||
@@map("prompt_versions")
|
||||
}
|
||||
|
||||
model ChangeRequest {
|
||||
id String @id @default(cuid())
|
||||
originalContent String @db.Text
|
||||
originalTitle String
|
||||
proposedContent String @db.Text
|
||||
proposedTitle String?
|
||||
reason String?
|
||||
status ChangeRequestStatus @default(PENDING)
|
||||
reviewNote String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
promptId String
|
||||
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
authorId String
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([promptId])
|
||||
@@index([authorId])
|
||||
@@index([status])
|
||||
@@map("change_requests")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Categories & Tags
|
||||
// ============================================
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
icon String?
|
||||
order Int @default(0)
|
||||
|
||||
// Self-referential for hierarchy
|
||||
parentId String?
|
||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children Category[] @relation("CategoryHierarchy")
|
||||
|
||||
prompts Prompt[]
|
||||
subscribers CategorySubscription[]
|
||||
|
||||
@@index([parentId])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
slug String @unique
|
||||
color String @default("#6366f1")
|
||||
|
||||
prompts PromptTag[]
|
||||
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
model PromptTag {
|
||||
promptId String
|
||||
tagId String
|
||||
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([promptId, tagId])
|
||||
@@map("prompt_tags")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Subscriptions
|
||||
// ============================================
|
||||
|
||||
model CategorySubscription {
|
||||
userId String
|
||||
categoryId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, categoryId])
|
||||
@@index([userId])
|
||||
@@index([categoryId])
|
||||
@@map("category_subscriptions")
|
||||
}
|
||||
|
||||
model PromptVote {
|
||||
userId String
|
||||
promptId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, promptId])
|
||||
@@index([userId])
|
||||
@@index([promptId])
|
||||
@@map("prompt_votes")
|
||||
}
|
||||
|
||||
model PinnedPrompt {
|
||||
userId String
|
||||
promptId String
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, promptId])
|
||||
@@index([userId])
|
||||
@@map("pinned_prompts")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Webhook Integration
|
||||
// ============================================
|
||||
|
||||
enum WebhookEvent {
|
||||
PROMPT_CREATED
|
||||
PROMPT_UPDATED
|
||||
PROMPT_DELETED
|
||||
}
|
||||
|
||||
model WebhookConfig {
|
||||
id String @id @default(cuid())
|
||||
name String // e.g. "Slack Notifications"
|
||||
url String // Webhook URL
|
||||
method String @default("POST") // HTTP method
|
||||
headers Json? // Custom headers as JSON object
|
||||
payload String @db.Text // JSON template with placeholders
|
||||
events WebhookEvent[] // Which events trigger this webhook
|
||||
isEnabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("webhook_configs")
|
||||
}
|
||||
616
prisma/seed.ts
Normal file
616
prisma/seed.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding database...");
|
||||
|
||||
// Create users
|
||||
const password = await bcrypt.hash("password123", 12);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: "admin@prompts.chat" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "admin@prompts.chat",
|
||||
username: "admin",
|
||||
name: "Admin User",
|
||||
password: password,
|
||||
role: "ADMIN",
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
|
||||
const demo = await prisma.user.upsert({
|
||||
where: { email: "demo@prompts.chat" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "demo@prompts.chat",
|
||||
username: "demo",
|
||||
name: "Demo User",
|
||||
password: password,
|
||||
role: "USER",
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
|
||||
const alice = await prisma.user.upsert({
|
||||
where: { email: "alice@example.com" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "alice@example.com",
|
||||
username: "alice",
|
||||
name: "Alice Johnson",
|
||||
password: password,
|
||||
role: "USER",
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
|
||||
const bob = await prisma.user.upsert({
|
||||
where: { email: "bob@example.com" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "bob@example.com",
|
||||
username: "bob",
|
||||
name: "Bob Smith",
|
||||
password: password,
|
||||
role: "USER",
|
||||
locale: "tr",
|
||||
},
|
||||
});
|
||||
|
||||
const charlie = await prisma.user.upsert({
|
||||
where: { email: "charlie@example.com" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "charlie@example.com",
|
||||
username: "charlie",
|
||||
name: "Charlie Brown",
|
||||
password: password,
|
||||
role: "USER",
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
|
||||
const users = [admin, demo, alice, bob, charlie];
|
||||
console.log("✅ Created", users.length, "users");
|
||||
|
||||
// Create parent categories
|
||||
const coding = await prisma.category.upsert({
|
||||
where: { slug: "coding" },
|
||||
update: {},
|
||||
create: { name: "Coding", slug: "coding", description: "Programming and development prompts", icon: "💻", order: 1 },
|
||||
});
|
||||
|
||||
const writing = await prisma.category.upsert({
|
||||
where: { slug: "writing" },
|
||||
update: {},
|
||||
create: { name: "Writing", slug: "writing", description: "Content writing and copywriting", icon: "✍️", order: 2 },
|
||||
});
|
||||
|
||||
const business = await prisma.category.upsert({
|
||||
where: { slug: "business" },
|
||||
update: {},
|
||||
create: { name: "Business", slug: "business", description: "Business strategy and operations", icon: "💼", order: 3 },
|
||||
});
|
||||
|
||||
const creative = await prisma.category.upsert({
|
||||
where: { slug: "creative" },
|
||||
update: {},
|
||||
create: { name: "Creative", slug: "creative", description: "Art, design, and creative work", icon: "🎨", order: 4 },
|
||||
});
|
||||
|
||||
const education = await prisma.category.upsert({
|
||||
where: { slug: "education" },
|
||||
update: {},
|
||||
create: { name: "Education", slug: "education", description: "Learning and teaching prompts", icon: "📚", order: 5 },
|
||||
});
|
||||
|
||||
// Create nested categories (children)
|
||||
const webDev = await prisma.category.upsert({
|
||||
where: { slug: "web-development" },
|
||||
update: {},
|
||||
create: { name: "Web Development", slug: "web-development", description: "Frontend and backend web development", parentId: coding.id, order: 1 },
|
||||
});
|
||||
|
||||
const mobileDev = await prisma.category.upsert({
|
||||
where: { slug: "mobile-development" },
|
||||
update: {},
|
||||
create: { name: "Mobile Development", slug: "mobile-development", description: "iOS and Android development", parentId: coding.id, order: 2 },
|
||||
});
|
||||
|
||||
const devops = await prisma.category.upsert({
|
||||
where: { slug: "devops" },
|
||||
update: {},
|
||||
create: { name: "DevOps", slug: "devops", description: "CI/CD, cloud, and infrastructure", parentId: coding.id, order: 3 },
|
||||
});
|
||||
|
||||
const dataScience = await prisma.category.upsert({
|
||||
where: { slug: "data-science" },
|
||||
update: {},
|
||||
create: { name: "Data Science", slug: "data-science", description: "ML, AI, and data analysis", parentId: coding.id, order: 4 },
|
||||
});
|
||||
|
||||
const blogWriting = await prisma.category.upsert({
|
||||
where: { slug: "blog-writing" },
|
||||
update: {},
|
||||
create: { name: "Blog Writing", slug: "blog-writing", description: "Blog posts and articles", parentId: writing.id, order: 1 },
|
||||
});
|
||||
|
||||
const copywriting = await prisma.category.upsert({
|
||||
where: { slug: "copywriting" },
|
||||
update: {},
|
||||
create: { name: "Copywriting", slug: "copywriting", description: "Sales and marketing copy", parentId: writing.id, order: 2 },
|
||||
});
|
||||
|
||||
const technicalWriting = await prisma.category.upsert({
|
||||
where: { slug: "technical-writing" },
|
||||
update: {},
|
||||
create: { name: "Technical Writing", slug: "technical-writing", description: "Documentation and guides", parentId: writing.id, order: 3 },
|
||||
});
|
||||
|
||||
const marketing = await prisma.category.upsert({
|
||||
where: { slug: "marketing" },
|
||||
update: {},
|
||||
create: { name: "Marketing", slug: "marketing", description: "Marketing and advertising prompts", parentId: business.id, order: 1 },
|
||||
});
|
||||
|
||||
const sales = await prisma.category.upsert({
|
||||
where: { slug: "sales" },
|
||||
update: {},
|
||||
create: { name: "Sales", slug: "sales", description: "Sales strategies and outreach", parentId: business.id, order: 2 },
|
||||
});
|
||||
|
||||
const hr = await prisma.category.upsert({
|
||||
where: { slug: "hr" },
|
||||
update: {},
|
||||
create: { name: "HR & Recruiting", slug: "hr", description: "Human resources and hiring", parentId: business.id, order: 3 },
|
||||
});
|
||||
|
||||
const imageGen = await prisma.category.upsert({
|
||||
where: { slug: "image-generation" },
|
||||
update: {},
|
||||
create: { name: "Image Generation", slug: "image-generation", description: "AI image prompts", parentId: creative.id, order: 1 },
|
||||
});
|
||||
|
||||
const music = await prisma.category.upsert({
|
||||
where: { slug: "music" },
|
||||
update: {},
|
||||
create: { name: "Music", slug: "music", description: "Music and audio generation", parentId: creative.id, order: 2 },
|
||||
});
|
||||
|
||||
// Create Workflows category (for structured prompts)
|
||||
const workflows = await prisma.category.upsert({
|
||||
where: { slug: "workflows" },
|
||||
update: {},
|
||||
create: { name: "Workflows", slug: "workflows", description: "Structured AI workflows and pipelines", icon: "⚡", order: 6 },
|
||||
});
|
||||
|
||||
const agentWorkflows = await prisma.category.upsert({
|
||||
where: { slug: "agent-workflows" },
|
||||
update: {},
|
||||
create: { name: "Agent Workflows", slug: "agent-workflows", description: "Multi-step AI agent configurations", parentId: workflows.id, order: 1 },
|
||||
});
|
||||
|
||||
const automations = await prisma.category.upsert({
|
||||
where: { slug: "automations" },
|
||||
update: {},
|
||||
create: { name: "Automations", slug: "automations", description: "Automated task pipelines", parentId: workflows.id, order: 2 },
|
||||
});
|
||||
|
||||
const categories = [coding, writing, business, creative, education, workflows, webDev, mobileDev, devops, dataScience, blogWriting, copywriting, technicalWriting, marketing, sales, hr, imageGen, music, agentWorkflows, automations];
|
||||
console.log("✅ Created", categories.length, "categories (including nested)");
|
||||
|
||||
// Create tags
|
||||
const tags = await Promise.all([
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "gpt-4" },
|
||||
update: {},
|
||||
create: { name: "GPT-4", slug: "gpt-4", color: "#10B981" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "claude" },
|
||||
update: {},
|
||||
create: { name: "Claude", slug: "claude", color: "#8B5CF6" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "midjourney" },
|
||||
update: {},
|
||||
create: { name: "Midjourney", slug: "midjourney", color: "#F59E0B" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "dalle" },
|
||||
update: {},
|
||||
create: { name: "DALL-E", slug: "dalle", color: "#EC4899" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "beginner" },
|
||||
update: {},
|
||||
create: { name: "Beginner", slug: "beginner", color: "#06B6D4" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "advanced" },
|
||||
update: {},
|
||||
create: { name: "Advanced", slug: "advanced", color: "#EF4444" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "chain-of-thought" },
|
||||
update: {},
|
||||
create: { name: "Chain of Thought", slug: "chain-of-thought", color: "#3B82F6" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "system-prompt" },
|
||||
update: {},
|
||||
create: { name: "System Prompt", slug: "system-prompt", color: "#6366F1" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "workflow" },
|
||||
update: {},
|
||||
create: { name: "Workflow", slug: "workflow", color: "#F97316" },
|
||||
}),
|
||||
prisma.tag.upsert({
|
||||
where: { slug: "agent" },
|
||||
update: {},
|
||||
create: { name: "Agent", slug: "agent", color: "#14B8A6" },
|
||||
}),
|
||||
]);
|
||||
console.log("✅ Created", tags.length, "tags");
|
||||
|
||||
// Create sample prompts - distributed among users
|
||||
const prompts = [
|
||||
// Admin's prompts
|
||||
{ title: "Code Review Assistant", description: "A prompt for thorough code reviews", content: `You are an expert code reviewer. Review the following code:\n\n{{code}}\n\nProvide:\n1. Summary\n2. Strengths\n3. Issues\n4. Improvements\n5. Performance suggestions`, type: "TEXT", categorySlug: "web-development", tagSlugs: ["gpt-4", "advanced"], authorId: admin.id },
|
||||
{ title: "Expert System Prompt", description: "Turn AI into a domain expert", content: `You are an expert {{role}} with {{years}} years of experience in {{domain}}. Be precise, professional, and provide actionable insights.`, type: "TEXT", categorySlug: "coding", tagSlugs: ["system-prompt", "advanced", "gpt-4"], authorId: admin.id },
|
||||
{ title: "Lesson Plan Creator", description: "Create structured lesson plans", content: `Create a lesson plan for {{subject}} - {{topic}} for {{grade}} level. Include objectives, materials, activities, and assessment.`, type: "TEXT", categorySlug: "education", tagSlugs: ["gpt-4", "beginner"], authorId: admin.id },
|
||||
{ title: "DALL-E Product Mockup", description: "Professional product mockups", content: `A professional product photo of {{product}} on {{surface}}, soft studio lighting, high-end advertising style, 4k`, type: "IMAGE", categorySlug: "image-generation", tagSlugs: ["dalle", "beginner"], authorId: admin.id },
|
||||
{ title: "Email Response Generator", description: "Professional email responses", content: `Write a professional email response to: {{original_email}}\n\nTone: {{tone}}\nDesired outcome: {{outcome}}`, type: "TEXT", categorySlug: "business", tagSlugs: ["gpt-4", "beginner"], authorId: admin.id },
|
||||
|
||||
// Demo user's prompts
|
||||
{ title: "Blog Post Generator", description: "Generate engaging blog posts", content: `Write a blog post about {{topic}} for {{audience}}. Include engaging intro, 3-5 sections, practical tips, and CTA. Word count: {{word_count}}`, type: "TEXT", categorySlug: "blog-writing", tagSlugs: ["gpt-4", "beginner"], authorId: demo.id },
|
||||
{ title: "SWOT Analysis Generator", description: "Comprehensive SWOT analysis", content: `Conduct a SWOT analysis for {{subject}} in {{industry}}. Provide Strengths, Weaknesses, Opportunities, Threats, and Strategic Recommendations.`, type: "TEXT", categorySlug: "business", tagSlugs: ["gpt-4", "chain-of-thought"], authorId: demo.id },
|
||||
{ title: "Midjourney Fantasy Landscape", description: "Stunning fantasy landscape images", content: `A breathtaking fantasy landscape, {{scene_type}}, ancient ruins, floating islands, dramatic lighting, volumetric fog, 8k --ar 16:9 --v 6`, type: "IMAGE", categorySlug: "image-generation", tagSlugs: ["midjourney", "advanced"], authorId: demo.id },
|
||||
{ title: "Debug Assistant", description: "Systematic debugging help", content: `Debug this issue:\n\nError: {{error}}\nCode: {{code}}\nTried: {{attempts}}\n\nExplain the error, root cause, fix, and prevention.`, type: "TEXT", categorySlug: "coding", tagSlugs: ["gpt-4", "claude", "chain-of-thought"], authorId: demo.id },
|
||||
|
||||
// Alice's prompts
|
||||
{ title: "React Component Generator", description: "Generate React components with TypeScript", content: `Create a React component for {{component_name}} with TypeScript. Include props interface, hooks if needed, and basic styling.`, type: "TEXT", categorySlug: "web-development", tagSlugs: ["gpt-4", "advanced"], authorId: alice.id },
|
||||
{ title: "API Documentation Writer", description: "Generate API documentation", content: `Write API documentation for {{endpoint}}:\n\nMethod: {{method}}\nParameters: {{params}}\nResponse: {{response}}\n\nInclude examples and error codes.`, type: "TEXT", categorySlug: "technical-writing", tagSlugs: ["gpt-4", "beginner"], authorId: alice.id },
|
||||
{ title: "Social Media Content Calendar", description: "Plan social media content", content: `Create a {{duration}} social media content calendar for {{brand}} targeting {{audience}}. Include post ideas, hashtags, and best posting times.`, type: "TEXT", categorySlug: "marketing", tagSlugs: ["claude", "beginner"], authorId: alice.id },
|
||||
{ title: "UX Research Questions", description: "Generate user research questions", content: `Create user research questions for {{product}} focusing on {{feature}}. Include open-ended, rating, and follow-up questions.`, type: "TEXT", categorySlug: "business", tagSlugs: ["gpt-4", "beginner"], authorId: alice.id },
|
||||
|
||||
// Bob's prompts
|
||||
{ title: "SQL Query Optimizer", description: "Optimize SQL queries", content: `Analyze and optimize this SQL query:\n\n{{query}}\n\nProvide: execution plan analysis, index suggestions, and optimized query.`, type: "TEXT", categorySlug: "data-science", tagSlugs: ["gpt-4", "advanced"], authorId: bob.id },
|
||||
{ title: "Docker Compose Generator", description: "Generate Docker Compose files", content: `Create a Docker Compose file for {{stack}} with {{services}}. Include environment variables, volumes, and networks.`, type: "TEXT", categorySlug: "devops", tagSlugs: ["gpt-4", "advanced"], authorId: bob.id },
|
||||
{ title: "Job Description Writer", description: "Write compelling job descriptions", content: `Write a job description for {{position}} at {{company}}. Include responsibilities, requirements, benefits, and company culture.`, type: "TEXT", categorySlug: "hr", tagSlugs: ["claude", "beginner"], authorId: bob.id },
|
||||
{ title: "Sales Email Sequence", description: "Create sales email sequences", content: `Create a {{length}}-email sequence for {{product}} targeting {{persona}}. Include subject lines, personalization, and CTAs.`, type: "TEXT", categorySlug: "sales", tagSlugs: ["gpt-4", "advanced"], authorId: bob.id },
|
||||
|
||||
// Charlie's prompts
|
||||
{ title: "Mobile App Onboarding Flow", description: "Design onboarding experiences", content: `Design an onboarding flow for {{app_name}} ({{platform}}). Include screens, copy, and user actions for first-time users.`, type: "TEXT", categorySlug: "mobile-development", tagSlugs: ["claude", "beginner"], authorId: charlie.id },
|
||||
{ title: "Product Copywriter", description: "Compelling product copy", content: `Write product copy for {{product_name}}:\n\nFeatures: {{features}}\nTarget: {{target}}\n\nInclude headline, benefits, and CTA.`, type: "TEXT", categorySlug: "copywriting", tagSlugs: ["gpt-4", "beginner"], authorId: charlie.id },
|
||||
{ title: "Meeting Notes Summarizer", description: "Summarize meeting notes", content: `Summarize this meeting:\n\n{{transcript}}\n\nProvide: key decisions, action items, owners, and deadlines.`, type: "TEXT", categorySlug: "business", tagSlugs: ["gpt-4", "beginner"], authorId: charlie.id },
|
||||
{ title: "Music Prompt Generator", description: "Generate music with AI", content: `Create a {{genre}} track with {{mood}} feel. BPM: {{bpm}}, Key: {{key}}. Include intro, verse, chorus structure.`, type: "AUDIO", categorySlug: "music", tagSlugs: ["claude", "advanced"], authorId: charlie.id },
|
||||
|
||||
// Structured prompts (workflows)
|
||||
{
|
||||
title: "Content Pipeline Workflow",
|
||||
description: "Multi-step content creation pipeline",
|
||||
content: JSON.stringify({
|
||||
name: "Content Creation Pipeline",
|
||||
version: "1.0",
|
||||
steps: [
|
||||
{
|
||||
id: "research",
|
||||
name: "Research Topic",
|
||||
prompt: "Research the topic '{{topic}}' and provide 5 key points with sources",
|
||||
output: "research_results"
|
||||
},
|
||||
{
|
||||
id: "outline",
|
||||
name: "Create Outline",
|
||||
prompt: "Based on {{research_results}}, create a detailed blog post outline",
|
||||
output: "outline",
|
||||
depends_on: ["research"]
|
||||
},
|
||||
{
|
||||
id: "draft",
|
||||
name: "Write Draft",
|
||||
prompt: "Write a {{word_count}} word blog post following this outline: {{outline}}",
|
||||
output: "draft",
|
||||
depends_on: ["outline"]
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
name: "Review & Edit",
|
||||
prompt: "Review and improve this draft for clarity, grammar, and engagement: {{draft}}",
|
||||
output: "final_content",
|
||||
depends_on: ["draft"]
|
||||
}
|
||||
],
|
||||
variables: {
|
||||
topic: { type: "string", required: true },
|
||||
word_count: { type: "number", default: 1500 }
|
||||
}
|
||||
}, null, 2),
|
||||
type: "STRUCTURED",
|
||||
structuredFormat: "JSON",
|
||||
categorySlug: "automations",
|
||||
tagSlugs: ["workflow", "gpt-4", "advanced"],
|
||||
authorId: admin.id
|
||||
},
|
||||
{
|
||||
title: "Code Review Agent",
|
||||
description: "AI agent for comprehensive code reviews",
|
||||
content: `name: Code Review Agent
|
||||
version: "1.0"
|
||||
description: Multi-pass code review agent
|
||||
|
||||
agent:
|
||||
role: Senior Software Engineer
|
||||
expertise:
|
||||
- Code quality
|
||||
- Security
|
||||
- Performance optimization
|
||||
- Best practices
|
||||
|
||||
workflow:
|
||||
- step: security_scan
|
||||
prompt: |
|
||||
Analyze this code for security vulnerabilities:
|
||||
\`\`\`{{language}}
|
||||
{{code}}
|
||||
\`\`\`
|
||||
Focus on: injection, XSS, authentication issues
|
||||
output: security_report
|
||||
|
||||
- step: performance_review
|
||||
prompt: |
|
||||
Review this code for performance issues:
|
||||
{{code}}
|
||||
Consider: time complexity, memory usage, database queries
|
||||
output: performance_report
|
||||
depends_on: [security_scan]
|
||||
|
||||
- step: best_practices
|
||||
prompt: |
|
||||
Check adherence to {{language}} best practices:
|
||||
{{code}}
|
||||
output: practices_report
|
||||
depends_on: [security_scan]
|
||||
|
||||
- step: final_summary
|
||||
prompt: |
|
||||
Compile a final code review report from:
|
||||
- Security: {{security_report}}
|
||||
- Performance: {{performance_report}}
|
||||
- Best Practices: {{practices_report}}
|
||||
output: final_review
|
||||
depends_on: [performance_review, best_practices]
|
||||
|
||||
variables:
|
||||
code:
|
||||
type: string
|
||||
required: true
|
||||
language:
|
||||
type: string
|
||||
default: typescript`,
|
||||
type: "STRUCTURED",
|
||||
structuredFormat: "YAML",
|
||||
categorySlug: "agent-workflows",
|
||||
tagSlugs: ["agent", "workflow", "claude", "advanced"],
|
||||
authorId: demo.id
|
||||
},
|
||||
{
|
||||
title: "Customer Support Agent",
|
||||
description: "Intelligent customer support workflow",
|
||||
content: JSON.stringify({
|
||||
name: "Customer Support Agent",
|
||||
version: "1.0",
|
||||
agent: {
|
||||
role: "Customer Support Specialist",
|
||||
tone: "friendly, professional, helpful",
|
||||
constraints: [
|
||||
"Never share internal policies",
|
||||
"Escalate billing issues over $1000",
|
||||
"Always verify customer identity first"
|
||||
]
|
||||
},
|
||||
workflow: [
|
||||
{
|
||||
step: "classify",
|
||||
prompt: "Classify this customer inquiry: {{inquiry}}\nCategories: billing, technical, general, complaint",
|
||||
output: "category"
|
||||
},
|
||||
{
|
||||
step: "gather_context",
|
||||
prompt: "What additional information is needed to resolve this {{category}} issue? List specific questions.",
|
||||
output: "followup_questions",
|
||||
depends_on: ["classify"]
|
||||
},
|
||||
{
|
||||
step: "resolve",
|
||||
prompt: "Provide a helpful response for this {{category}} issue: {{inquiry}}\nContext gathered: {{context}}",
|
||||
output: "response",
|
||||
depends_on: ["gather_context"]
|
||||
}
|
||||
],
|
||||
variables: {
|
||||
inquiry: { type: "string", required: true },
|
||||
context: { type: "string", default: "" }
|
||||
}
|
||||
}, null, 2),
|
||||
type: "STRUCTURED",
|
||||
structuredFormat: "JSON",
|
||||
categorySlug: "agent-workflows",
|
||||
tagSlugs: ["agent", "workflow", "gpt-4", "beginner"],
|
||||
authorId: alice.id
|
||||
},
|
||||
{
|
||||
title: "Data Processing Pipeline",
|
||||
description: "ETL-style data transformation workflow",
|
||||
content: `name: Data Processing Pipeline
|
||||
version: "1.0"
|
||||
description: Transform and analyze data with AI
|
||||
|
||||
pipeline:
|
||||
- stage: extract
|
||||
name: Data Extraction
|
||||
prompt: |
|
||||
Extract structured data from this input:
|
||||
{{raw_data}}
|
||||
|
||||
Output as JSON with fields: {{fields}}
|
||||
output: extracted_data
|
||||
|
||||
- stage: transform
|
||||
name: Data Transformation
|
||||
prompt: |
|
||||
Transform this data according to rules:
|
||||
Data: {{extracted_data}}
|
||||
Rules: {{transformation_rules}}
|
||||
output: transformed_data
|
||||
depends_on: [extract]
|
||||
|
||||
- stage: validate
|
||||
name: Data Validation
|
||||
prompt: |
|
||||
Validate this data against schema:
|
||||
Data: {{transformed_data}}
|
||||
Schema: {{validation_schema}}
|
||||
Report any errors or inconsistencies.
|
||||
output: validation_report
|
||||
depends_on: [transform]
|
||||
|
||||
- stage: analyze
|
||||
name: Data Analysis
|
||||
prompt: |
|
||||
Analyze this validated data:
|
||||
{{transformed_data}}
|
||||
|
||||
Provide: summary statistics, patterns, anomalies
|
||||
output: analysis_report
|
||||
depends_on: [validate]
|
||||
|
||||
variables:
|
||||
raw_data:
|
||||
type: string
|
||||
required: true
|
||||
fields:
|
||||
type: array
|
||||
default: [id, name, value, timestamp]
|
||||
transformation_rules:
|
||||
type: string
|
||||
default: "normalize dates, clean text, convert currencies"
|
||||
validation_schema:
|
||||
type: string
|
||||
default: "standard"`,
|
||||
type: "STRUCTURED",
|
||||
structuredFormat: "YAML",
|
||||
categorySlug: "automations",
|
||||
tagSlugs: ["workflow", "claude", "advanced"],
|
||||
authorId: bob.id
|
||||
},
|
||||
];
|
||||
|
||||
for (const promptData of prompts) {
|
||||
const category = categories.find((c) => c.slug === promptData.categorySlug);
|
||||
const promptTags = tags.filter((t) => promptData.tagSlugs.includes(t.slug));
|
||||
|
||||
const existingPrompt = await prisma.prompt.findFirst({
|
||||
where: { title: promptData.title, authorId: promptData.authorId },
|
||||
});
|
||||
|
||||
if (!existingPrompt) {
|
||||
const prompt = await prisma.prompt.create({
|
||||
data: {
|
||||
title: promptData.title,
|
||||
description: promptData.description,
|
||||
content: promptData.content,
|
||||
type: promptData.type as "TEXT" | "IMAGE" | "VIDEO" | "AUDIO" | "STRUCTURED",
|
||||
structuredFormat: (promptData as { structuredFormat?: "JSON" | "YAML" }).structuredFormat,
|
||||
authorId: promptData.authorId,
|
||||
categoryId: category?.id,
|
||||
tags: {
|
||||
create: promptTags.map((tag) => ({ tagId: tag.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create initial version
|
||||
await prisma.promptVersion.create({
|
||||
data: {
|
||||
promptId: prompt.id,
|
||||
version: 1,
|
||||
content: promptData.content,
|
||||
changeNote: "Initial version",
|
||||
createdBy: promptData.authorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log("✅ Created", prompts.length, "prompts");
|
||||
|
||||
// Create comprehensive change requests
|
||||
const allPrompts = await prisma.prompt.findMany();
|
||||
|
||||
const changeRequestsData = [
|
||||
// Pending requests
|
||||
{ id: "cr-1", promptTitle: "Code Review Assistant", authorId: demo.id, reason: "Added best practices section", proposedAddition: "\n\n6. **Best Practices**: Suggest industry best practices.", status: "PENDING" as const },
|
||||
{ id: "cr-2", promptTitle: "Blog Post Generator", authorId: alice.id, reason: "Added SEO tips", proposedAddition: "\n\nInclude meta description and keyword suggestions.", status: "PENDING" as const },
|
||||
{ id: "cr-3", promptTitle: "React Component Generator", authorId: bob.id, reason: "Add accessibility considerations", proposedAddition: "\n\nEnsure ARIA labels and keyboard navigation support.", status: "PENDING" as const },
|
||||
{ id: "cr-4", promptTitle: "SQL Query Optimizer", authorId: charlie.id, reason: "Include explain analyze output", proposedAddition: "\n\nShow EXPLAIN ANALYZE output interpretation.", status: "PENDING" as const },
|
||||
{ id: "cr-5", promptTitle: "Expert System Prompt", authorId: demo.id, reason: "Add error handling guidance", proposedAddition: "\n\nHandle edge cases gracefully and provide fallback responses.", status: "PENDING" as const },
|
||||
|
||||
// Approved requests
|
||||
{ id: "cr-6", promptTitle: "SWOT Analysis Generator", authorId: admin.id, reason: "Added competitive analysis section", proposedAddition: "\n\n## Competitive Position\nCompare against top 3 competitors.", status: "APPROVED" as const, reviewNote: "Great addition! Merged." },
|
||||
{ id: "cr-7", promptTitle: "Debug Assistant", authorId: alice.id, reason: "Include stack trace analysis", proposedAddition: "\n\n6. Analyze the full stack trace if provided.", status: "APPROVED" as const, reviewNote: "Very helpful improvement." },
|
||||
{ id: "cr-8", promptTitle: "Docker Compose Generator", authorId: demo.id, reason: "Add health checks", proposedAddition: "\n\nInclude healthcheck configurations for each service.", status: "APPROVED" as const, reviewNote: "Essential for production setups." },
|
||||
{ id: "cr-9", promptTitle: "API Documentation Writer", authorId: bob.id, reason: "Add rate limiting info", proposedAddition: "\n\nDocument rate limits and throttling policies.", status: "APPROVED" as const, reviewNote: "Good call, this is often missing." },
|
||||
|
||||
// Rejected requests
|
||||
{ id: "cr-10", promptTitle: "Midjourney Fantasy Landscape", authorId: admin.id, reason: "Simplify the prompt", proposedAddition: " (simplified)", status: "REJECTED" as const, reviewNote: "I prefer the detailed version for better results." },
|
||||
{ id: "cr-11", promptTitle: "Sales Email Sequence", authorId: charlie.id, reason: "Make it more aggressive", proposedAddition: "\n\nBe more pushy and urgent.", status: "REJECTED" as const, reviewNote: "This goes against our brand voice guidelines." },
|
||||
{ id: "cr-12", promptTitle: "Job Description Writer", authorId: alice.id, reason: "Remove benefits section", proposedAddition: "", status: "REJECTED" as const, reviewNote: "Benefits are important for attracting candidates." },
|
||||
];
|
||||
|
||||
for (const cr of changeRequestsData) {
|
||||
const prompt = allPrompts.find(p => p.title === cr.promptTitle);
|
||||
if (prompt) {
|
||||
await prisma.changeRequest.upsert({
|
||||
where: { id: cr.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: cr.id,
|
||||
promptId: prompt.id,
|
||||
authorId: cr.authorId,
|
||||
originalContent: prompt.content,
|
||||
originalTitle: prompt.title,
|
||||
proposedContent: prompt.content + cr.proposedAddition,
|
||||
proposedTitle: null,
|
||||
reason: cr.reason,
|
||||
status: cr.status,
|
||||
reviewNote: cr.reviewNote || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ Created", changeRequestsData.length, "change requests");
|
||||
|
||||
console.log("\n🎉 Seeding complete!");
|
||||
console.log("\n📋 Test credentials (all passwords: password123):");
|
||||
console.log(" Admin: admin@prompts.chat");
|
||||
console.log(" Demo: demo@prompts.chat");
|
||||
console.log(" Alice: alice@example.com");
|
||||
console.log(" Bob: bob@example.com");
|
||||
console.log(" Charlie: charlie@example.com");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("❌ Seeding failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
57
prompts.config.ts
Normal file
57
prompts.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { defineConfig } from "@/lib/config";
|
||||
|
||||
export default defineConfig({
|
||||
// Branding - customize for white-label
|
||||
branding: {
|
||||
name: "prompts.chat",
|
||||
logo: "/logo.svg",
|
||||
favicon: "/favicon.ico",
|
||||
description: "Collect, organize, and share AI prompts",
|
||||
},
|
||||
|
||||
// Theme - design system configuration
|
||||
theme: {
|
||||
// Border radius: "none" | "sm" | "md" | "lg"
|
||||
radius: "sm",
|
||||
// UI style: "flat" | "default" | "brutal"
|
||||
variant: "default",
|
||||
// Spacing density: "compact" | "default" | "comfortable"
|
||||
density: "compact",
|
||||
// Colors (hex or oklch)
|
||||
colors: {
|
||||
primary: "#6366f1", // Indigo
|
||||
},
|
||||
},
|
||||
|
||||
// Authentication plugin
|
||||
auth: {
|
||||
// Available: "credentials" | "google" | "azure" | custom
|
||||
provider: "credentials",
|
||||
// Allow public registration
|
||||
allowRegistration: true,
|
||||
},
|
||||
|
||||
// Storage plugin for media uploads
|
||||
storage: {
|
||||
// Available: "url" | "s3" | custom
|
||||
provider: "url",
|
||||
},
|
||||
|
||||
// Internationalization
|
||||
i18n: {
|
||||
locales: ["en", "tr", "es", "zh", "ja"],
|
||||
defaultLocale: "en",
|
||||
},
|
||||
|
||||
// Features
|
||||
features: {
|
||||
// Allow users to create private prompts
|
||||
privatePrompts: true,
|
||||
// Enable change request system for versioning
|
||||
changeRequests: true,
|
||||
// Enable categories
|
||||
categories: true,
|
||||
// Enable tags
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -s https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv | tail +2 | ruby -rcsv -e 'CSV.parse(STDIN.read) {|row| puts row.join("\t")}' | fzf -1 -q "$1" --with-nth 1 --delimiter "\t" --preview 'echo {2} | fold -s -w $(tput cols)' | cut -d" " -f2
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
title: /github sponsors profile
|
||||
subtitle: Improve your GitHub Sponsors profile with AI-powered prompts
|
||||
hide_platform_selector: true
|
||||
hide_extension_link: true
|
||||
hide_tone_selector: true
|
||||
subpage: true
|
||||
body_class: sponsors
|
||||
---
|
||||
|
||||
<script src="script.js"></script>
|
||||
@@ -1,142 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"category": "Crafting a Compelling Bio",
|
||||
"title": "Create a Professional Bio",
|
||||
"prompt": "Write a GitHub Sponsors bio for my profile that highlights my experience in [your field], the impact of my open source work, and my commitment to community growth.",
|
||||
"tags": ["bio", "profile", "introduction"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"category": "Crafting a Compelling Bio",
|
||||
"title": "Showcase Top Repositories",
|
||||
"prompt": "Summarize my top three repositories ([repo1], [repo2], [repo3]) in a way that inspires potential sponsors to support my work.",
|
||||
"tags": ["bio", "repositories", "showcase"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"category": "Explaining Why Funding Matters",
|
||||
"title": "Explain Funding Impact",
|
||||
"prompt": "Create a section for my Sponsors page that explains how funding will help me dedicate more time to [project/topics], support new contributors, and ensure the sustainability of my open source work.",
|
||||
"tags": ["funding", "impact", "sustainability"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"category": "Explaining Why Funding Matters",
|
||||
"title": "Show Direct Impact",
|
||||
"prompt": "Write a paragraph that shows sponsors the direct impact their funding will have on my projects and the wider community.",
|
||||
"tags": ["funding", "impact", "community"]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"category": "Unlocking New Pricing Tiers",
|
||||
"title": "Suggest Pricing Tiers",
|
||||
"prompt": "Suggest ideas for pricing tiers on GitHub Sponsors, including unique benefits at each level for individuals and companies.",
|
||||
"tags": ["pricing", "tiers", "benefits"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"category": "Unlocking New Pricing Tiers",
|
||||
"title": "Write Tier Descriptions",
|
||||
"prompt": "Write descriptions for three GitHub Sponsors tiers ($5, $25, $100) that offer increasing value and recognition to supporters.",
|
||||
"tags": ["pricing", "tiers", "descriptions"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"category": "Spotlighting Your Project",
|
||||
"title": "Create Project Spotlight",
|
||||
"prompt": "Draft a brief 'Project Spotlight' section for my Sponsors page, showcasing the goals, achievements, and roadmap of [project name].",
|
||||
"tags": ["project", "spotlight", "roadmap"]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"category": "Spotlighting Your Project",
|
||||
"title": "Announce Milestone",
|
||||
"prompt": "Write an announcement for my Sponsors page about a new milestone or feature in [project], encouraging new and existing sponsors to get involved.",
|
||||
"tags": ["project", "milestone", "announcement"]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"category": "Enhancing Community Engagement",
|
||||
"title": "Recognize Sponsors",
|
||||
"prompt": "List ways I can recognize or involve sponsors in my project's community (e.g., special Discord roles, early feature access, private Q&A sessions).",
|
||||
"tags": ["community", "engagement", "recognition"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"category": "Enhancing Community Engagement",
|
||||
"title": "Creative Perks",
|
||||
"prompt": "Suggest creative perks or acknowledgments for sponsors to foster a sense of belonging and appreciation.",
|
||||
"tags": ["community", "perks", "appreciation"]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"category": "Crafting a Compelling Bio",
|
||||
"title": "Tell Your Story",
|
||||
"prompt": "Write a personal story about why I started contributing to open source, what drives me, and how sponsorship helps me continue this journey in [field/technology].",
|
||||
"tags": ["bio", "story", "personal"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"category": "Explaining Why Funding Matters",
|
||||
"title": "Break Down Costs",
|
||||
"prompt": "Create a transparent breakdown of how sponsor funds will be used (e.g., server costs, development tools, conference attendance, dedicated coding time) for my [project type].",
|
||||
"tags": ["funding", "transparency", "costs"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"category": "Unlocking New Pricing Tiers",
|
||||
"title": "Enterprise Sponsorship",
|
||||
"prompt": "Design enterprise-level sponsorship tiers ($500, $1000, $5000) with benefits like priority support, custom features, and brand visibility for my [project].",
|
||||
"tags": ["pricing", "enterprise", "tiers"]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"category": "Spotlighting Your Project",
|
||||
"title": "Impact Metrics",
|
||||
"prompt": "Create a compelling data-driven section showing the impact of [project name]: downloads, users helped, issues resolved, and community growth statistics.",
|
||||
"tags": ["project", "metrics", "impact"]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"category": "Enhancing Community Engagement",
|
||||
"title": "Sponsor Hall of Fame",
|
||||
"prompt": "Design a 'Sponsor Hall of Fame' section for my README and Sponsors page that creatively showcases and thanks all contributors at different tiers.",
|
||||
"tags": ["community", "recognition", "readme"]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"category": "Crafting a Compelling Bio",
|
||||
"title": "Future Vision",
|
||||
"prompt": "Write a compelling vision statement about where I see [project/work] going in the next 2-3 years and how sponsors can be part of that journey.",
|
||||
"tags": ["bio", "vision", "future"]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"category": "Explaining Why Funding Matters",
|
||||
"title": "Time Commitment",
|
||||
"prompt": "Explain how sponsorship would allow me to dedicate [X hours/days] per week/month to open source, comparing current volunteer time vs. potential sponsored time.",
|
||||
"tags": ["funding", "time", "commitment"]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"category": "Unlocking New Pricing Tiers",
|
||||
"title": "Student Tier",
|
||||
"prompt": "Create a special $1-2 student sponsorship tier with meaningful benefits that acknowledges their support while respecting their budget.",
|
||||
"tags": ["pricing", "students", "accessibility"]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"category": "Spotlighting Your Project",
|
||||
"title": "Success Stories",
|
||||
"prompt": "Write 3-5 brief success stories or testimonials from users who have benefited from [project name], showing real-world impact.",
|
||||
"tags": ["project", "testimonials", "success"]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"category": "Enhancing Community Engagement",
|
||||
"title": "Monthly Updates",
|
||||
"prompt": "Create a template for monthly sponsor updates that includes progress, challenges, wins, and upcoming features for [project].",
|
||||
"tags": ["community", "updates", "communication"]
|
||||
}
|
||||
]
|
||||
@@ -1,208 +0,0 @@
|
||||
// GitHub Sponsors Profile Improver Script
|
||||
|
||||
// Dark mode functionality (global scope for onclick handler)
|
||||
window.toggleDarkMode = function() {
|
||||
const body = document.body;
|
||||
const toggle = document.querySelector(".dark-mode-toggle");
|
||||
const sunIcon = toggle.querySelector(".sun-icon");
|
||||
const moonIcon = toggle.querySelector(".moon-icon");
|
||||
|
||||
body.classList.toggle("dark-mode");
|
||||
const isDarkMode = body.classList.contains("dark-mode");
|
||||
|
||||
localStorage.setItem("dark-mode", isDarkMode);
|
||||
sunIcon.style.display = isDarkMode ? "none" : "block";
|
||||
moonIcon.style.display = isDarkMode ? "block" : "none";
|
||||
};
|
||||
|
||||
(function() {
|
||||
let sponsorPrompts = [];
|
||||
|
||||
// Load prompts from JSON file
|
||||
async function loadSponsorsPrompts() {
|
||||
try {
|
||||
const response = await fetch('/sponsors/prompts.json');
|
||||
sponsorPrompts = await response.json();
|
||||
renderSponsorsPrompts();
|
||||
renderSidebarPrompts();
|
||||
} catch (error) {
|
||||
console.error('Error loading prompts:', error);
|
||||
const container = document.querySelector('#promptContent');
|
||||
if (container) {
|
||||
container.innerHTML = '<p style="text-align: center; padding: 2rem; color: var(--text-secondary);">Error loading prompts. Please try again later.</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy prompt to clipboard
|
||||
window.copySponsorPrompt = function(button, promptText) {
|
||||
navigator.clipboard.writeText(promptText).then(() => {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
`;
|
||||
button.style.background = '#10b981';
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.style.background = '';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Open prompt in GitHub Copilot
|
||||
window.openInCopilot = function(promptText) {
|
||||
const encodedPrompt = encodeURIComponent(promptText);
|
||||
const copilotUrl = `https://github.com/copilot?prompt=${encodedPrompt}`;
|
||||
window.open(copilotUrl, '_blank');
|
||||
}
|
||||
|
||||
// Render prompts in the sidebar
|
||||
function renderSidebarPrompts() {
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
if (searchResults && sponsorPrompts.length > 0) {
|
||||
searchResults.innerHTML = sponsorPrompts.map(prompt => `
|
||||
<li class="search-result-item" onclick="scrollToPrompt('${prompt.title.replace(/'/g, "\\'")}')">${prompt.title}</li>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to prompt card function
|
||||
window.scrollToPrompt = function(title) {
|
||||
const cards = document.querySelectorAll('.prompt-card');
|
||||
const targetCard = Array.from(cards).find(card => {
|
||||
const cardTitle = card.querySelector('.prompt-title')?.textContent?.trim();
|
||||
return cardTitle && cardTitle.includes(title);
|
||||
});
|
||||
|
||||
if (targetCard) {
|
||||
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
targetCard.style.transform = 'scale(1.02)';
|
||||
targetCard.style.boxShadow = '0 8px 16px rgba(16, 185, 129, 0.2)';
|
||||
targetCard.style.borderColor = '#10b981';
|
||||
|
||||
setTimeout(() => {
|
||||
targetCard.style.transform = '';
|
||||
targetCard.style.boxShadow = '';
|
||||
targetCard.style.borderColor = '';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Render prompts in the main content area
|
||||
function renderSponsorsPrompts() {
|
||||
const container = document.querySelector('#promptContent');
|
||||
if (!container) return;
|
||||
|
||||
let html = '<div class="prompts-grid">';
|
||||
|
||||
// Add a contribute card first (matching main site style)
|
||||
html += `
|
||||
<div class="prompt-card contribute-card">
|
||||
<a href="https://github.com/f/awesome-chatgpt-prompts/blob/main/sponsors/prompts.json" target="_blank" style="text-decoration: none; color: inherit; height: 100%; display: flex; flex-direction: column;">
|
||||
<div class="prompt-title" style="display: flex; align-items: center; gap: 8px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="16"></line>
|
||||
<line x1="8" y1="12" x2="16" y2="12"></line>
|
||||
</svg>
|
||||
Add Your Sponsors Prompt
|
||||
</div>
|
||||
<p class="prompt-content" style="flex-grow: 1;">
|
||||
Have a great prompt for improving GitHub Sponsors profiles? Contribute to <code>sponsors/prompts.json</code> in our repository!
|
||||
</p>
|
||||
<span class="contributor-badge">Contribute Now</span>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add all prompts
|
||||
sponsorPrompts.forEach(prompt => {
|
||||
html += `
|
||||
<div class="prompt-card">
|
||||
<div class="prompt-title">
|
||||
${prompt.title}
|
||||
<div class="action-buttons">
|
||||
<button class="copy-button" title="Copy prompt" onclick="copySponsorPrompt(this, \`${prompt.prompt.replace(/`/g, '\\`')}\`)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="copy-button" title="Try in GitHub Copilot" onclick="openInCopilot(\`${prompt.prompt.replace(/`/g, '\\`')}\`)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="prompt-content">${prompt.prompt}</p>
|
||||
<div class="card-footer">
|
||||
<div class="techstack-badges">
|
||||
${prompt.tags.map(tag => `<span class="tech-badge">${tag}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Update prompt count
|
||||
const countElement = document.getElementById('promptCount');
|
||||
if (countElement) {
|
||||
const countNumber = countElement.querySelector('.count-number');
|
||||
if (countNumber) {
|
||||
countNumber.textContent = sponsorPrompts.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch GitHub stars
|
||||
async function fetchGitHubStars() {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/f/awesome-chatgpt-prompts');
|
||||
const data = await response.json();
|
||||
const starCount = document.getElementById('starCount');
|
||||
if (starCount) {
|
||||
starCount.textContent = data.stargazers_count.toLocaleString();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stars:', error);
|
||||
const starCount = document.getElementById('starCount');
|
||||
if (starCount) {
|
||||
starCount.textContent = '110k+'; // Fallback value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Check if we're on the sponsors page
|
||||
if (document.body.classList.contains('sponsors')) {
|
||||
loadSponsorsPrompts();
|
||||
fetchGitHubStars();
|
||||
|
||||
// Hide cursor badge on sponsors page
|
||||
const cursorLogo = document.querySelector('.cursor-logo');
|
||||
if (cursorLogo) {
|
||||
cursorLogo.style.display = 'none';
|
||||
}
|
||||
|
||||
// Apply dark mode preference
|
||||
const isDarkMode = localStorage.getItem("dark-mode") === "true";
|
||||
if (isDarkMode) {
|
||||
document.body.classList.add("dark-mode");
|
||||
const sunIcon = document.querySelector(".sun-icon");
|
||||
const moonIcon = document.querySelector(".moon-icon");
|
||||
if (sunIcon) sunIcon.style.display = "none";
|
||||
if (moonIcon) moonIcon.style.display = "block";
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
11
src/app/(auth)/layout.tsx
Normal file
11
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/app/(auth)/login/page.tsx
Normal file
31
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
description: "Login to your account",
|
||||
};
|
||||
|
||||
export default async function LoginPage() {
|
||||
const t = await getTranslations("auth");
|
||||
|
||||
return (
|
||||
<div className="container flex min-h-[calc(100vh-6rem)] flex-col items-center justify-center py-8">
|
||||
<div className="w-full max-w-sm space-y-4">
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-xl font-semibold">{t("login")}</h1>
|
||||
<p className="text-xs text-muted-foreground">{t("loginDescription")}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<LoginForm />
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("noAccount")}{" "}
|
||||
<Link href="/register" className="text-foreground hover:underline">{t("register")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/app/(auth)/register/page.tsx
Normal file
31
src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { RegisterForm } from "@/components/auth/register-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Register",
|
||||
description: "Create a new account",
|
||||
};
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const t = await getTranslations("auth");
|
||||
|
||||
return (
|
||||
<div className="container flex min-h-[calc(100vh-6rem)] flex-col items-center justify-center py-8">
|
||||
<div className="w-full max-w-sm space-y-4">
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-xl font-semibold">{t("register")}</h1>
|
||||
<p className="text-xs text-muted-foreground">{t("registerDescription")}</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("hasAccount")}{" "}
|
||||
<Link href="/login" className="text-foreground hover:underline">{t("login")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
398
src/app/[username]/page.tsx
Normal file
398
src/app/[username]/page.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations, getLocale } from "next-intl/server";
|
||||
import { formatDistanceToNow } from "@/lib/date";
|
||||
import { Calendar, ArrowBigUp, FileText, Settings, GitPullRequest, Clock, Check, X, Pin } from "lucide-react";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PromptList } from "@/components/prompts/prompt-list";
|
||||
import { PromptCard, type PromptCardProps } from "@/components/prompts/prompt-card";
|
||||
|
||||
interface UserProfilePageProps {
|
||||
params: Promise<{ username: string }>;
|
||||
searchParams: Promise<{ page?: string; tab?: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: UserProfilePageProps): Promise<Metadata> {
|
||||
const { username: rawUsername } = await params;
|
||||
const decodedUsername = decodeURIComponent(rawUsername);
|
||||
|
||||
// Support both /@username and /username formats
|
||||
const username = decodedUsername.startsWith("@")
|
||||
? decodedUsername.slice(1)
|
||||
: decodedUsername;
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { username },
|
||||
select: { name: true, username: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { title: "User Not Found" };
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${user.name || user.username} (@${user.username})`,
|
||||
description: `View ${user.name || user.username}'s prompts`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserProfilePage({ params, searchParams }: UserProfilePageProps) {
|
||||
const { username: rawUsername } = await params;
|
||||
const { page: pageParam, tab } = await searchParams;
|
||||
const session = await auth();
|
||||
const t = await getTranslations("user");
|
||||
const tChanges = await getTranslations("changeRequests");
|
||||
const tPrompts = await getTranslations("prompts");
|
||||
const locale = await getLocale();
|
||||
|
||||
// Decode URL-encoded @ symbol
|
||||
const decodedUsername = decodeURIComponent(rawUsername);
|
||||
|
||||
// Support both /@username and /username formats
|
||||
// Strip @ prefix if present
|
||||
const username = decodedUsername.startsWith("@")
|
||||
? decodedUsername.slice(1)
|
||||
: decodedUsername;
|
||||
|
||||
// Redirect old format to new @ format
|
||||
if (!decodedUsername.startsWith("@")) {
|
||||
// For now, just continue - could add redirect later
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { username },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
prompts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const page = parseInt(pageParam || "1");
|
||||
const perPage = 12;
|
||||
const isOwner = session?.user?.id === user.id;
|
||||
|
||||
// Build where clause - show private prompts only if owner
|
||||
const where = {
|
||||
authorId: user.id,
|
||||
...(isOwner ? {} : { isPrivate: false }),
|
||||
};
|
||||
|
||||
// Common prompt include for both queries
|
||||
const promptInclude = {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { votes: true, contributors: true },
|
||||
},
|
||||
};
|
||||
|
||||
// Fetch prompts, pinned prompts, and counts
|
||||
const [promptsRaw, total, totalUpvotes, pinnedPromptsRaw] = await Promise.all([
|
||||
db.prompt.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
include: promptInclude,
|
||||
}),
|
||||
db.prompt.count({ where }),
|
||||
db.promptVote.count({
|
||||
where: {
|
||||
prompt: {
|
||||
authorId: user.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.pinnedPrompt.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
prompt: {
|
||||
include: promptInclude,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Transform to include voteCount and contributorCount
|
||||
const prompts = promptsRaw.map((p) => ({
|
||||
...p,
|
||||
voteCount: p._count?.votes ?? 0,
|
||||
contributorCount: p._count?.contributors ?? 0,
|
||||
}));
|
||||
|
||||
// Transform pinned prompts - filter out private prompts for non-owners
|
||||
const pinnedPrompts = pinnedPromptsRaw
|
||||
.filter((pp) => isOwner || !pp.prompt.isPrivate)
|
||||
.map((pp) => ({
|
||||
...pp.prompt,
|
||||
voteCount: pp.prompt._count?.votes ?? 0,
|
||||
contributorCount: pp.prompt._count?.contributors ?? 0,
|
||||
}));
|
||||
|
||||
// Get set of pinned prompt IDs for easy lookup
|
||||
const pinnedIds = new Set<string>(pinnedPrompts.map((p: { id: string }) => p.id));
|
||||
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
// Fetch pending change requests for user's prompts (only if owner)
|
||||
const changeRequests = isOwner
|
||||
? await db.changeRequest.findMany({
|
||||
where: {
|
||||
prompt: {
|
||||
authorId: user.id,
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const pendingCount = changeRequests.filter((cr) => cr.status === "PENDING").length;
|
||||
const defaultTab = tab === "changes" ? "changes" : "prompts";
|
||||
|
||||
const statusColors = {
|
||||
PENDING: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
|
||||
APPROVED: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
||||
REJECTED: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
};
|
||||
|
||||
const statusIcons = {
|
||||
PENDING: Clock,
|
||||
APPROVED: Check,
|
||||
REJECTED: X,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
{/* Profile Header */}
|
||||
<div className="flex flex-col md:flex-row items-start gap-6 mb-8">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={user.avatar || undefined} />
|
||||
<AvatarFallback className="text-2xl">
|
||||
{user.name?.charAt(0) || user.username.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h1 className="text-2xl font-bold">{user.name || user.username}</h1>
|
||||
{user.role === "ADMIN" && (
|
||||
<Badge variant="default" className="text-xs">Admin</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-3">@{user.username}</p>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/settings">
|
||||
<Settings className="h-4 w-4 mr-1.5" />
|
||||
{t("editProfile")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{user._count.prompts}</span>
|
||||
<span className="text-muted-foreground">{t("prompts").toLowerCase()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowBigUp className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{totalUpvotes}</span>
|
||||
<span className="text-muted-foreground">{t("upvotesReceived")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{t("joined")} {formatDistanceToNow(user.createdAt, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs for Prompts and Change Requests */}
|
||||
{isOwner ? (
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="prompts" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t("prompts")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="changes" className="gap-2">
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
{tChanges("title")}
|
||||
{pendingCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-1 h-5 min-w-5 px-1 text-xs">
|
||||
{pendingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="prompts">
|
||||
{/* Pinned Prompts Section */}
|
||||
{pinnedPrompts.length > 0 && (
|
||||
<div className="mb-6 pb-6 border-b">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Pin className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-medium">{tPrompts("pinnedPrompts")}</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pinnedPrompts.map((prompt: PromptCardProps["prompt"]) => (
|
||||
<PromptCard key={prompt.id} prompt={prompt} showPinButton={isOwner} isPinned />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prompts.length === 0 && pinnedPrompts.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/30">
|
||||
<p className="text-muted-foreground">{t("noPromptsOwner")}</p>
|
||||
<Button asChild className="mt-4" size="sm">
|
||||
<Link href="/prompts/new">{t("createFirstPrompt")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : prompts.length > 0 ? (
|
||||
<>
|
||||
{pinnedPrompts.length > 0 && (
|
||||
<h3 className="text-sm font-medium mb-3">{t("allPrompts")}</h3>
|
||||
)}
|
||||
<PromptList
|
||||
prompts={prompts}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
pinnedIds={pinnedIds}
|
||||
showPinButton={isOwner}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="changes">
|
||||
{changeRequests.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/30">
|
||||
<GitPullRequest className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">{tChanges("noRequests")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-lg">
|
||||
{changeRequests.map((cr) => {
|
||||
const StatusIcon = statusIcons[cr.status];
|
||||
return (
|
||||
<Link
|
||||
key={cr.id}
|
||||
href={`/prompts/${cr.prompt.id}/changes/${cr.id}`}
|
||||
className="flex items-center justify-between px-3 py-2 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{cr.prompt.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(cr.createdAt, locale)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={`ml-2 shrink-0 ${statusColors[cr.status]}`}>
|
||||
<StatusIcon className="h-3 w-3 mr-1" />
|
||||
{tChanges(cr.status.toLowerCase())}
|
||||
</Badge>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div>
|
||||
{/* Pinned Prompts Section for non-owners */}
|
||||
{pinnedPrompts.length > 0 && (
|
||||
<div className="mb-6 pb-6 border-b">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Pin className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-medium">{tPrompts("pinnedPrompts")}</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pinnedPrompts.map((prompt: PromptCardProps["prompt"]) => (
|
||||
<PromptCard key={prompt.id} prompt={prompt} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prompts.length === 0 && pinnedPrompts.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/30">
|
||||
<p className="text-muted-foreground">{t("noPrompts")}</p>
|
||||
</div>
|
||||
) : prompts.length > 0 ? (
|
||||
<>
|
||||
{pinnedPrompts.length > 0 && (
|
||||
<h3 className="text-sm font-medium mb-3">{t("allPrompts")}</h3>
|
||||
)}
|
||||
<PromptList
|
||||
prompts={prompts}
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
src/app/admin/page.tsx
Normal file
173
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Users, FolderTree, Tags, FileText, TrendingUp, Webhook } 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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin Dashboard",
|
||||
description: "Manage your application",
|
||||
};
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await auth();
|
||||
const t = await getTranslations("admin");
|
||||
|
||||
// Check if user is admin
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
// Fetch stats
|
||||
const [userCount, promptCount, categoryCount, tagCount] = await Promise.all([
|
||||
db.user.count(),
|
||||
db.prompt.count(),
|
||||
db.category.count(),
|
||||
db.tag.count(),
|
||||
]);
|
||||
|
||||
// Fetch data for tables
|
||||
const [users, categories, tags, webhooks] = await Promise.all([
|
||||
db.user.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
prompts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.category.findMany({
|
||||
orderBy: [{ parentId: "asc" }, { order: "asc" }],
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
prompts: true,
|
||||
children: true,
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.tag.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
prompts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.webhookConfig.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("stats.users")}</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{userCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("stats.prompts")}</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{promptCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("stats.categories")}</CardTitle>
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{categoryCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t("stats.tags")}</CardTitle>
|
||||
<Tags className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{tagCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Management Tabs */}
|
||||
<Tabs defaultValue="users" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="users" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
{t("tabs.users")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="categories" className="gap-2">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
{t("tabs.categories")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="gap-2">
|
||||
<Tags className="h-4 w-4" />
|
||||
{t("tabs.tags")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="webhooks" className="gap-2">
|
||||
<Webhook className="h-4 w-4" />
|
||||
{t("tabs.webhooks")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<UsersTable users={users} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories">
|
||||
<CategoriesTable categories={categories} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags">
|
||||
<TagsTable tags={tags} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="webhooks">
|
||||
<WebhooksTable webhooks={webhooks} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/app/api/admin/categories/[id]/route.ts
Normal file
60
src/app/api/admin/categories/[id]/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// Update category
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ 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 { name, slug, description, icon, parentId } = body;
|
||||
|
||||
const category = await db.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
...(slug && { slug }),
|
||||
description: description ?? undefined,
|
||||
icon: icon ?? undefined,
|
||||
parentId: parentId === null ? null : (parentId || undefined),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(category);
|
||||
} catch (error) {
|
||||
console.error("Error updating category:", error);
|
||||
return NextResponse.json({ error: "Failed to update category" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete category
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ 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;
|
||||
|
||||
await db.category.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting category:", error);
|
||||
return NextResponse.json({ error: "Failed to delete category" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
src/app/api/admin/categories/route.ts
Normal file
35
src/app/api/admin/categories/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// Create category
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, slug, description, icon, parentId } = body;
|
||||
|
||||
if (!name || !slug) {
|
||||
return NextResponse.json({ error: "Name and slug are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const category = await db.category.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
description: description || null,
|
||||
icon: icon || null,
|
||||
parentId: parentId || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(category);
|
||||
} catch (error) {
|
||||
console.error("Error creating category:", error);
|
||||
return NextResponse.json({ error: "Failed to create category" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
58
src/app/api/admin/tags/[id]/route.ts
Normal file
58
src/app/api/admin/tags/[id]/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// Update tag
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ 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 { name, slug, color } = body;
|
||||
|
||||
const tag = await db.tag.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
...(slug && { slug }),
|
||||
...(color && { color }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(tag);
|
||||
} catch (error) {
|
||||
console.error("Error updating tag:", error);
|
||||
return NextResponse.json({ error: "Failed to update tag" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete tag
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ 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;
|
||||
|
||||
await db.tag.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting tag:", error);
|
||||
return NextResponse.json({ error: "Failed to delete tag" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/admin/tags/route.ts
Normal file
33
src/app/api/admin/tags/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// Create tag
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, slug, color } = body;
|
||||
|
||||
if (!name || !slug) {
|
||||
return NextResponse.json({ error: "Name and slug are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tag = await db.tag.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
color: color || "#6366f1",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(tag);
|
||||
} catch (error) {
|
||||
console.error("Error creating tag:", error);
|
||||
return NextResponse.json({ error: "Failed to create tag" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
63
src/app/api/admin/users/[id]/route.ts
Normal file
63
src/app/api/admin/users/[id]/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// Update user (role change)
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ 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 { role } = body;
|
||||
|
||||
if (!["ADMIN", "USER"].includes(role)) {
|
||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await db.user.update({
|
||||
where: { id },
|
||||
data: { role },
|
||||
});
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
return NextResponse.json({ error: "Failed to update user" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ 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;
|
||||
|
||||
// Don't allow deleting yourself
|
||||
if (id === session.user.id) {
|
||||
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.user.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
165
src/app/api/admin/webhooks/[id]/route.ts
Normal file
165
src/app/api/admin/webhooks/[id]/route.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const VALID_METHODS = ["GET", "POST", "PUT", "PATCH"];
|
||||
const VALID_EVENTS = ["PROMPT_CREATED", "PROMPT_UPDATED", "PROMPT_DELETED"];
|
||||
|
||||
interface UpdateWebhookData {
|
||||
name?: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string> | null;
|
||||
payload?: string;
|
||||
events?: string[];
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
function validateUpdateWebhook(body: unknown): { success: true; data: UpdateWebhookData } | { success: false; error: string } {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return { success: false, error: "Invalid request body" };
|
||||
}
|
||||
|
||||
const data = body as Record<string, unknown>;
|
||||
const result: UpdateWebhookData = {};
|
||||
|
||||
if (data.name !== undefined) {
|
||||
if (typeof data.name !== "string" || data.name.length < 1 || data.name.length > 100) {
|
||||
return { success: false, error: "Name must be a string between 1 and 100 characters" };
|
||||
}
|
||||
result.name = data.name;
|
||||
}
|
||||
|
||||
if (data.url !== undefined) {
|
||||
if (typeof data.url !== "string") {
|
||||
return { success: false, error: "URL must be a string" };
|
||||
}
|
||||
try {
|
||||
new URL(data.url);
|
||||
} catch {
|
||||
return { success: false, error: "Invalid URL format" };
|
||||
}
|
||||
result.url = data.url;
|
||||
}
|
||||
|
||||
if (data.method !== undefined) {
|
||||
if (typeof data.method !== "string" || !VALID_METHODS.includes(data.method)) {
|
||||
return { success: false, error: `Method must be one of: ${VALID_METHODS.join(", ")}` };
|
||||
}
|
||||
result.method = data.method;
|
||||
}
|
||||
|
||||
if (data.headers !== undefined) {
|
||||
if (data.headers !== null && typeof data.headers !== "object") {
|
||||
return { success: false, error: "Headers must be an object or null" };
|
||||
}
|
||||
result.headers = data.headers as Record<string, string> | null;
|
||||
}
|
||||
|
||||
if (data.payload !== undefined) {
|
||||
if (typeof data.payload !== "string" || data.payload.length < 1) {
|
||||
return { success: false, error: "Payload must be a non-empty string" };
|
||||
}
|
||||
result.payload = data.payload;
|
||||
}
|
||||
|
||||
if (data.events !== undefined) {
|
||||
if (!Array.isArray(data.events) || !data.events.every(e => typeof e === "string" && VALID_EVENTS.includes(e))) {
|
||||
return { success: false, error: `Events must be an array of: ${VALID_EVENTS.join(", ")}` };
|
||||
}
|
||||
result.events = data.events;
|
||||
}
|
||||
|
||||
if (data.isEnabled !== undefined) {
|
||||
if (typeof data.isEnabled !== "boolean") {
|
||||
return { success: false, error: "isEnabled must be a boolean" };
|
||||
}
|
||||
result.isEnabled = data.isEnabled;
|
||||
}
|
||||
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
// GET single webhook
|
||||
export async function GET(
|
||||
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 webhook = await db.webhookConfig.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(webhook);
|
||||
} catch (error) {
|
||||
console.error("Get webhook error:", error);
|
||||
return NextResponse.json({ error: "server_error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATE webhook
|
||||
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 validation = validateUpdateWebhook(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", details: validation.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const webhook = await db.webhookConfig.update({
|
||||
where: { id },
|
||||
data: validation.data,
|
||||
});
|
||||
|
||||
return NextResponse.json(webhook);
|
||||
} catch (error) {
|
||||
console.error("Update webhook error:", error);
|
||||
return NextResponse.json({ error: "server_error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE webhook
|
||||
export async function DELETE(
|
||||
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;
|
||||
await db.webhookConfig.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Delete webhook error:", error);
|
||||
return NextResponse.json({ error: "server_error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
95
src/app/api/admin/webhooks/[id]/test/route.ts
Normal file
95
src/app/api/admin/webhooks/[id]/test/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function POST(
|
||||
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;
|
||||
|
||||
// Get the webhook configuration
|
||||
const webhook = await db.webhookConfig.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return NextResponse.json({ error: "Webhook not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Replace placeholders with test values (must match WEBHOOK_PLACEHOLDERS)
|
||||
let payload = webhook.payload;
|
||||
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://prompts.chat";
|
||||
const testData: Record<string, string> = {
|
||||
"{{PROMPT_ID}}": "test-prompt-id-12345",
|
||||
"{{PROMPT_TITLE}}": "Test Prompt Title",
|
||||
"{{PROMPT_DESCRIPTION}}": "This is a test description for the webhook.",
|
||||
"{{PROMPT_CONTENT}}": "This is the test prompt content. It demonstrates how your webhook will receive data when a new prompt is created.",
|
||||
"{{PROMPT_TYPE}}": "TEXT",
|
||||
"{{PROMPT_URL}}": `${siteUrl}/prompts/test-prompt-id-12345`,
|
||||
"{{PROMPT_MEDIA_URL}}": "https://example.com/media/test-image.png",
|
||||
"{{AUTHOR_USERNAME}}": "testuser",
|
||||
"{{AUTHOR_NAME}}": "Test User",
|
||||
"{{AUTHOR_AVATAR}}": "https://avatars.githubusercontent.com/u/1234567",
|
||||
"{{CATEGORY_NAME}}": "Development",
|
||||
"{{TAGS}}": "testing, webhook, automation",
|
||||
"{{TIMESTAMP}}": new Date().toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
"{{SITE_URL}}": siteUrl,
|
||||
"{{CHATGPT_URL}}": `https://chat.openai.com/?prompt=${encodeURIComponent("This is the test prompt content. It demonstrates how your webhook will receive data when a new prompt is created.")}`,
|
||||
};
|
||||
|
||||
for (const [placeholder, value] of Object.entries(testData)) {
|
||||
payload = payload.replaceAll(placeholder, value);
|
||||
}
|
||||
|
||||
// Parse the payload as JSON
|
||||
let parsedPayload;
|
||||
try {
|
||||
parsedPayload = JSON.parse(payload);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON payload" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Send the test request
|
||||
const response = await fetch(webhook.url, {
|
||||
method: webhook.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(webhook.headers as Record<string, string> | null),
|
||||
},
|
||||
body: webhook.method !== "GET" ? JSON.stringify(parsedPayload) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Webhook returned ${response.status}: ${text}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Test webhook error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to test webhook" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
src/app/api/admin/webhooks/route.ts
Normal file
126
src/app/api/admin/webhooks/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const VALID_METHODS = ["GET", "POST", "PUT", "PATCH"] as const;
|
||||
const VALID_EVENTS = ["PROMPT_CREATED", "PROMPT_UPDATED", "PROMPT_DELETED"] as const;
|
||||
|
||||
type WebhookInput = {
|
||||
name: string;
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
payload: string;
|
||||
events: string[];
|
||||
isEnabled?: boolean;
|
||||
};
|
||||
|
||||
function validateWebhook(body: unknown): { success: true; data: WebhookInput } | { success: false; error: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
return { success: false, error: "Invalid request body" };
|
||||
}
|
||||
|
||||
const data = body as Record<string, unknown>;
|
||||
|
||||
if (!data.name || typeof data.name !== "string" || data.name.length < 1 || data.name.length > 100) {
|
||||
return { success: false, error: "Name is required (1-100 characters)" };
|
||||
}
|
||||
|
||||
if (!data.url || typeof data.url !== "string") {
|
||||
return { success: false, error: "URL is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(data.url);
|
||||
} catch {
|
||||
return { success: false, error: "Invalid URL" };
|
||||
}
|
||||
|
||||
const method = (data.method as string) || "POST";
|
||||
if (!VALID_METHODS.includes(method as typeof VALID_METHODS[number])) {
|
||||
return { success: false, error: "Invalid method" };
|
||||
}
|
||||
|
||||
if (!data.payload || typeof data.payload !== "string" || data.payload.length < 1) {
|
||||
return { success: false, error: "Payload is required" };
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.events) || data.events.length === 0) {
|
||||
return { success: false, error: "At least one event is required" };
|
||||
}
|
||||
|
||||
for (const event of data.events) {
|
||||
if (!VALID_EVENTS.includes(event as typeof VALID_EVENTS[number])) {
|
||||
return { success: false, error: `Invalid event: ${event}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
name: data.name as string,
|
||||
url: data.url as string,
|
||||
method,
|
||||
headers: data.headers as Record<string, string> | undefined,
|
||||
payload: data.payload as string,
|
||||
events: data.events as string[],
|
||||
isEnabled: data.isEnabled !== false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// GET all webhooks
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const webhooks = await db.webhookConfig.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(webhooks);
|
||||
} catch (error) {
|
||||
console.error("Get webhooks error:", error);
|
||||
return NextResponse.json({ error: "server_error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// CREATE webhook
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = validateWebhook(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", message: parsed.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const webhook = await db.webhookConfig.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
url: parsed.data.url,
|
||||
method: parsed.data.method || "POST",
|
||||
headers: parsed.data.headers || null,
|
||||
payload: parsed.data.payload,
|
||||
events: parsed.data.events,
|
||||
isEnabled: parsed.data.isEnabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(webhook);
|
||||
} catch (error) {
|
||||
console.error("Create webhook error:", error);
|
||||
return NextResponse.json({ error: "server_error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
77
src/app/api/auth/register/route.ts
Normal file
77
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
username: z.string().min(3).regex(/^[a-zA-Z0-9_]+$/),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", message: "Invalid input" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { name, username, email, password } = parsed.data;
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await db.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: "email_taken", message: "Email is already taken" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await db.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (existingUsername) {
|
||||
return NextResponse.json(
|
||||
{ error: "username_taken", message: "Username is already taken" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create user
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
name,
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Registration error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
109
src/app/api/categories/[id]/subscribe/route.ts
Normal file
109
src/app/api/categories/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// POST - Subscribe to a category
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: categoryId } = await params;
|
||||
|
||||
// Check if category exists
|
||||
const category = await db.category.findUnique({
|
||||
where: { id: categoryId },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Category not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
const existing = await db.categorySubscription.findUnique({
|
||||
where: {
|
||||
userId_categoryId: {
|
||||
userId: session.user.id,
|
||||
categoryId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "already_subscribed", message: "Already subscribed to this category" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create subscription
|
||||
const subscription = await db.categorySubscription.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
categoryId,
|
||||
},
|
||||
include: {
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ subscribed: true, category: subscription.category });
|
||||
} catch (error) {
|
||||
console.error("Subscribe error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Unsubscribe from a category
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: categoryId } = await params;
|
||||
|
||||
// Delete subscription
|
||||
await db.categorySubscription.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ subscribed: false });
|
||||
} catch (error) {
|
||||
console.error("Unsubscribe error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
220
src/app/api/prompts/[id]/changes/[changeId]/route.ts
Normal file
220
src/app/api/prompts/[id]/changes/[changeId]/route.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const updateChangeRequestSchema = z.object({
|
||||
status: z.enum(["APPROVED", "REJECTED", "PENDING"]),
|
||||
reviewNote: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; changeId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: promptId, changeId } = await params;
|
||||
|
||||
// Check if prompt exists and user is owner
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
select: { authorId: true, content: true, title: true },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Prompt not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt.authorId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "forbidden", message: "Only the prompt owner can review change requests" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get change request
|
||||
const changeRequest = await db.changeRequest.findUnique({
|
||||
where: { id: changeId },
|
||||
select: {
|
||||
id: true,
|
||||
promptId: true,
|
||||
status: true,
|
||||
proposedContent: true,
|
||||
proposedTitle: true,
|
||||
authorId: true,
|
||||
reason: true,
|
||||
author: {
|
||||
select: { username: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!changeRequest || changeRequest.promptId !== promptId) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Change request not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updateChangeRequestSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", message: "Invalid input", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { status, reviewNote } = parsed.data;
|
||||
|
||||
// Validate state transitions
|
||||
if (changeRequest.status === "PENDING" && status === "PENDING") {
|
||||
return NextResponse.json(
|
||||
{ error: "invalid_state", message: "Change request is already pending" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (changeRequest.status === "APPROVED") {
|
||||
return NextResponse.json(
|
||||
{ error: "invalid_state", message: "Cannot modify an approved change request" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Allow reopening rejected requests (REJECTED -> PENDING)
|
||||
if (changeRequest.status === "REJECTED" && status !== "PENDING") {
|
||||
return NextResponse.json(
|
||||
{ error: "invalid_state", message: "Rejected requests can only be reopened" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If reopening, just update status
|
||||
if (status === "PENDING") {
|
||||
await db.changeRequest.update({
|
||||
where: { id: changeId },
|
||||
data: { status, reviewNote: null },
|
||||
});
|
||||
return NextResponse.json({ success: true, status });
|
||||
}
|
||||
|
||||
// If approving, also update the prompt content
|
||||
if (status === "APPROVED") {
|
||||
// Get current version number
|
||||
const latestVersion = await db.promptVersion.findFirst({
|
||||
where: { promptId },
|
||||
orderBy: { version: "desc" },
|
||||
select: { version: true },
|
||||
});
|
||||
|
||||
const nextVersion = (latestVersion?.version ?? 0) + 1;
|
||||
|
||||
// Build change note with contributor info
|
||||
const changeNote = changeRequest.reason
|
||||
? `Contribution by @${changeRequest.author.username}: ${changeRequest.reason}`
|
||||
: `Contribution by @${changeRequest.author.username}`;
|
||||
|
||||
// Update prompt and create version in transaction
|
||||
await db.$transaction([
|
||||
// Create version record with the NEW content (the approved change)
|
||||
db.promptVersion.create({
|
||||
data: {
|
||||
prompt: { connect: { id: promptId } },
|
||||
content: changeRequest.proposedContent,
|
||||
changeNote,
|
||||
version: nextVersion,
|
||||
author: { connect: { id: changeRequest.authorId } },
|
||||
},
|
||||
}),
|
||||
// Update prompt with proposed changes and add contributor
|
||||
db.prompt.update({
|
||||
where: { id: promptId },
|
||||
data: {
|
||||
content: changeRequest.proposedContent,
|
||||
...(changeRequest.proposedTitle && { title: changeRequest.proposedTitle }),
|
||||
contributors: {
|
||||
connect: { id: changeRequest.authorId },
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Update change request status
|
||||
db.changeRequest.update({
|
||||
where: { id: changeId },
|
||||
data: { status, reviewNote },
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
// Just update the change request status
|
||||
await db.changeRequest.update({
|
||||
where: { id: changeId },
|
||||
data: { status, reviewNote },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, status });
|
||||
} catch (error) {
|
||||
console.error("Update change request error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; changeId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id: promptId, changeId } = await params;
|
||||
|
||||
const changeRequest = await db.changeRequest.findUnique({
|
||||
where: { id: changeId },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!changeRequest || changeRequest.prompt.id !== promptId) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Change request not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(changeRequest);
|
||||
} catch (error) {
|
||||
console.error("Get change request error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
120
src/app/api/prompts/[id]/changes/route.ts
Normal file
120
src/app/api/prompts/[id]/changes/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const createChangeRequestSchema = z.object({
|
||||
proposedContent: z.string().min(1),
|
||||
proposedTitle: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: promptId } = await params;
|
||||
|
||||
// Check if prompt exists
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
select: { id: true, authorId: true, isPrivate: true, content: true, title: true },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Prompt not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Can't create change request for your own prompt
|
||||
if (prompt.authorId === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "forbidden", message: "You cannot create a change request for your own prompt" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Can't create change request for private prompts
|
||||
if (prompt.isPrivate) {
|
||||
return NextResponse.json(
|
||||
{ error: "forbidden", message: "Cannot create change request for private prompts" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createChangeRequestSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", message: "Invalid input", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { proposedContent, proposedTitle, reason } = parsed.data;
|
||||
|
||||
const changeRequest = await db.changeRequest.create({
|
||||
data: {
|
||||
originalContent: prompt.content,
|
||||
originalTitle: prompt.title,
|
||||
proposedContent,
|
||||
proposedTitle,
|
||||
reason,
|
||||
promptId,
|
||||
authorId: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(changeRequest, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Create change request error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id: promptId } = await params;
|
||||
|
||||
const changeRequests = await db.changeRequest.findMany({
|
||||
where: { promptId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(changeRequests);
|
||||
} catch (error) {
|
||||
console.error("Get change requests error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
111
src/app/api/prompts/[id]/pin/route.ts
Normal file
111
src/app/api/prompts/[id]/pin/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const MAX_PINNED_PROMPTS = 3;
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: promptId } = await params;
|
||||
|
||||
// Check if prompt exists and belongs to user
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
select: { authorId: true, isPrivate: true },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json({ error: "Prompt not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (prompt.authorId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "You can only pin your own prompts" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already pinned
|
||||
const existingPin = await db.pinnedPrompt.findUnique({
|
||||
where: {
|
||||
userId_promptId: {
|
||||
userId: session.user.id,
|
||||
promptId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPin) {
|
||||
return NextResponse.json({ error: "Prompt already pinned" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check pin limit
|
||||
const pinnedCount = await db.pinnedPrompt.count({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
|
||||
if (pinnedCount >= MAX_PINNED_PROMPTS) {
|
||||
return NextResponse.json(
|
||||
{ error: `You can only pin up to ${MAX_PINNED_PROMPTS} prompts` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get next order number
|
||||
const maxOrder = await db.pinnedPrompt.aggregate({
|
||||
where: { userId: session.user.id },
|
||||
_max: { order: true },
|
||||
});
|
||||
|
||||
const nextOrder = (maxOrder._max.order ?? -1) + 1;
|
||||
|
||||
// Create pin
|
||||
await db.pinnedPrompt.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
promptId,
|
||||
order: nextOrder,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, pinned: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to pin prompt:", error);
|
||||
return NextResponse.json({ error: "Failed to pin prompt" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id: promptId } = await params;
|
||||
|
||||
// Delete the pin
|
||||
await db.pinnedPrompt.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
promptId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, pinned: false });
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin prompt:", error);
|
||||
return NextResponse.json({ error: "Failed to unpin prompt" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
232
src/app/api/prompts/[id]/route.ts
Normal file
232
src/app/api/prompts/[id]/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const updatePromptSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
type: z.enum(["TEXT", "IMAGE", "VIDEO", "AUDIO", "STRUCTURED"]).optional(),
|
||||
structuredFormat: z.enum(["JSON", "YAML"]).optional().nullable(),
|
||||
categoryId: z.string().optional().nullable(),
|
||||
tagIds: z.array(z.string()).optional(),
|
||||
isPrivate: z.boolean().optional(),
|
||||
mediaUrl: z.string().url().optional().or(z.literal("")).nullable(),
|
||||
requiresMediaUpload: z.boolean().optional(),
|
||||
requiredMediaType: z.enum(["IMAGE", "VIDEO", "DOCUMENT"]).optional().nullable(),
|
||||
requiredMediaCount: z.number().int().min(1).max(10).optional().nullable(),
|
||||
});
|
||||
|
||||
// Get single prompt
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
orderBy: { version: "desc" },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Prompt not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user can view private prompt
|
||||
if (prompt.isPrivate && prompt.authorId !== session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "forbidden", message: "This prompt is private" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(prompt);
|
||||
} catch (error) {
|
||||
console.error("Get prompt error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update prompt
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if prompt exists and user owns it
|
||||
const existing = await db.prompt.findUnique({
|
||||
where: { id },
|
||||
select: { authorId: true, content: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Prompt not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.authorId !== session.user.id && session.user.role !== "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{ error: "forbidden", message: "You can only edit your own prompts" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updatePromptSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", message: "Invalid input", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { tagIds, ...data } = parsed.data;
|
||||
|
||||
// Update prompt
|
||||
const prompt = await db.prompt.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(tagIds && {
|
||||
tags: {
|
||||
deleteMany: {},
|
||||
create: tagIds.map((tagId) => ({ tagId })),
|
||||
},
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create new version if content changed
|
||||
if (data.content && data.content !== existing.content) {
|
||||
const latestVersion = await db.promptVersion.findFirst({
|
||||
where: { promptId: id },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
|
||||
await db.promptVersion.create({
|
||||
data: {
|
||||
promptId: id,
|
||||
version: (latestVersion?.version || 0) + 1,
|
||||
content: data.content,
|
||||
changeNote: "Content updated",
|
||||
createdBy: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(prompt);
|
||||
} catch (error) {
|
||||
console.error("Update prompt error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete prompt
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if prompt exists and user owns it
|
||||
const existing = await db.prompt.findUnique({
|
||||
where: { id },
|
||||
select: { authorId: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Prompt not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.authorId !== session.user.id && session.user.role !== "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{ error: "forbidden", message: "You can only delete your own prompts" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.prompt.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Delete prompt error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
66
src/app/api/prompts/[id]/versions/[versionId]/route.ts
Normal file
66
src/app/api/prompts/[id]/versions/[versionId]/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; versionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: promptId, versionId } = await params;
|
||||
|
||||
// Check if prompt exists and user is owner
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
select: { authorId: true },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Prompt not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt.authorId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "forbidden", message: "You can only delete versions of your own prompts" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if version exists
|
||||
const version = await db.promptVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
select: { id: true, promptId: true },
|
||||
});
|
||||
|
||||
if (!version || version.promptId !== promptId) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Version not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the version
|
||||
await db.promptVersion.delete({
|
||||
where: { id: versionId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Delete version error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
140
src/app/api/prompts/[id]/versions/route.ts
Normal file
140
src/app/api/prompts/[id]/versions/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const createVersionSchema = z.object({
|
||||
content: z.string().min(1, "Content is required"),
|
||||
changeNote: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
// POST - Create a new version
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: promptId } = await params;
|
||||
|
||||
// Check if prompt exists and user is owner
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
select: { authorId: true, content: true },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Prompt not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt.authorId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "forbidden", message: "You can only add versions to your own prompts" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createVersionSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", message: "Invalid input", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { content, changeNote } = parsed.data;
|
||||
|
||||
// Check if content is different
|
||||
if (content === prompt.content) {
|
||||
return NextResponse.json(
|
||||
{ error: "no_change", message: "Content is the same as current version" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get latest version number
|
||||
const latestVersion = await db.promptVersion.findFirst({
|
||||
where: { promptId },
|
||||
orderBy: { version: "desc" },
|
||||
select: { version: true },
|
||||
});
|
||||
|
||||
const newVersionNumber = (latestVersion?.version || 0) + 1;
|
||||
|
||||
// Create new version and update prompt content in a transaction
|
||||
const [version] = await db.$transaction([
|
||||
db.promptVersion.create({
|
||||
data: {
|
||||
promptId,
|
||||
version: newVersionNumber,
|
||||
content,
|
||||
changeNote: changeNote || `Version ${newVersionNumber}`,
|
||||
createdBy: session.user.id,
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.prompt.update({
|
||||
where: { id: promptId },
|
||||
data: { content },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json(version, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Create version error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET - Get all versions
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id: promptId } = await params;
|
||||
|
||||
const versions = await db.promptVersion.findMany({
|
||||
where: { promptId },
|
||||
orderBy: { version: "desc" },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(versions);
|
||||
} catch (error) {
|
||||
console.error("Get versions error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
110
src/app/api/prompts/[id]/vote/route.ts
Normal file
110
src/app/api/prompts/[id]/vote/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// POST - Upvote a prompt
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: promptId } = await params;
|
||||
|
||||
// Check if prompt exists
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "Prompt not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already voted
|
||||
const existing = await db.promptVote.findUnique({
|
||||
where: {
|
||||
userId_promptId: {
|
||||
userId: session.user.id,
|
||||
promptId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "already_voted", message: "You have already upvoted this prompt" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create vote
|
||||
await db.promptVote.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
promptId,
|
||||
},
|
||||
});
|
||||
|
||||
// Get updated vote count
|
||||
const voteCount = await db.promptVote.count({
|
||||
where: { promptId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ voted: true, voteCount });
|
||||
} catch (error) {
|
||||
console.error("Vote error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Remove upvote from a prompt
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: promptId } = await params;
|
||||
|
||||
// Delete vote
|
||||
await db.promptVote.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
promptId,
|
||||
},
|
||||
});
|
||||
|
||||
// Get updated vote count
|
||||
const voteCount = await db.promptVote.count({
|
||||
where: { promptId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ voted: false, voteCount });
|
||||
} catch (error) {
|
||||
console.error("Unvote error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
225
src/app/api/prompts/route.ts
Normal file
225
src/app/api/prompts/route.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { triggerWebhooks } from "@/lib/webhook";
|
||||
|
||||
const promptSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(500).optional(),
|
||||
content: z.string().min(1),
|
||||
type: z.enum(["TEXT", "IMAGE", "VIDEO", "AUDIO", "STRUCTURED"]),
|
||||
structuredFormat: z.enum(["JSON", "YAML"]).optional(),
|
||||
categoryId: z.string().optional(),
|
||||
tagIds: z.array(z.string()),
|
||||
isPrivate: z.boolean(),
|
||||
mediaUrl: z.string().url().optional().or(z.literal("")),
|
||||
requiresMediaUpload: z.boolean().optional(),
|
||||
requiredMediaType: z.enum(["IMAGE", "VIDEO", "DOCUMENT"]).optional(),
|
||||
requiredMediaCount: z.number().int().min(1).max(10).optional(),
|
||||
});
|
||||
|
||||
// Create prompt
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = promptSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", message: "Invalid input", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { title, description, content, type, structuredFormat, categoryId, tagIds, isPrivate, mediaUrl, requiresMediaUpload, requiredMediaType, requiredMediaCount } = parsed.data;
|
||||
|
||||
// Create prompt with tags
|
||||
const prompt = await db.prompt.create({
|
||||
data: {
|
||||
title,
|
||||
description: description || null,
|
||||
content,
|
||||
type,
|
||||
structuredFormat: type === "STRUCTURED" ? structuredFormat : null,
|
||||
isPrivate,
|
||||
mediaUrl: mediaUrl || null,
|
||||
requiresMediaUpload: requiresMediaUpload || false,
|
||||
requiredMediaType: requiresMediaUpload ? requiredMediaType : null,
|
||||
requiredMediaCount: requiresMediaUpload ? requiredMediaCount : null,
|
||||
authorId: session.user.id,
|
||||
categoryId: categoryId || null,
|
||||
tags: {
|
||||
create: tagIds.map((tagId) => ({
|
||||
tagId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create initial version
|
||||
await db.promptVersion.create({
|
||||
data: {
|
||||
promptId: prompt.id,
|
||||
version: 1,
|
||||
content,
|
||||
changeNote: "Initial version",
|
||||
createdBy: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger webhooks for new prompt (non-blocking)
|
||||
if (!isPrivate) {
|
||||
triggerWebhooks("PROMPT_CREATED", {
|
||||
id: prompt.id,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
content: prompt.content,
|
||||
type: prompt.type,
|
||||
mediaUrl: prompt.mediaUrl,
|
||||
isPrivate: prompt.isPrivate,
|
||||
author: prompt.author,
|
||||
category: prompt.category,
|
||||
tags: prompt.tags,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(prompt);
|
||||
} catch (error) {
|
||||
console.error("Create prompt error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// List prompts (for API access)
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const perPage = parseInt(searchParams.get("perPage") || "12");
|
||||
const type = searchParams.get("type");
|
||||
const categoryId = searchParams.get("category");
|
||||
const tag = searchParams.get("tag");
|
||||
const sort = searchParams.get("sort");
|
||||
const q = searchParams.get("q");
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
isPrivate: false,
|
||||
};
|
||||
|
||||
if (type) {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
where.categoryId = categoryId;
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
tag: { slug: tag },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ content: { contains: q, mode: "insensitive" } },
|
||||
{ description: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Build order by clause
|
||||
let orderBy: any = { createdAt: "desc" };
|
||||
if (sort === "oldest") {
|
||||
orderBy = { createdAt: "asc" };
|
||||
} else if (sort === "upvotes") {
|
||||
orderBy = { votes: { _count: "desc" } };
|
||||
}
|
||||
|
||||
const [promptsRaw, total] = await Promise.all([
|
||||
db.prompt.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { votes: true, contributors: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.prompt.count({ where }),
|
||||
]);
|
||||
|
||||
// Transform to include voteCount and contributorCount
|
||||
const prompts = promptsRaw.map((p) => ({
|
||||
...p,
|
||||
voteCount: p._count.votes,
|
||||
contributorCount: p._count.contributors,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
prompts,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("List prompts error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/app/api/user/notifications/route.ts
Normal file
27
src/app/api/user/notifications/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ pendingChangeRequests: 0 });
|
||||
}
|
||||
|
||||
// Count pending change requests on user's prompts
|
||||
const pendingCount = await db.changeRequest.count({
|
||||
where: {
|
||||
status: "PENDING",
|
||||
prompt: {
|
||||
authorId: session.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ pendingChangeRequests: pendingCount });
|
||||
} catch (error) {
|
||||
console.error("Get notifications error:", error);
|
||||
return NextResponse.json({ pendingChangeRequests: 0 });
|
||||
}
|
||||
}
|
||||
118
src/app/api/user/profile/route.ts
Normal file
118
src/app/api/user/profile/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(30)
|
||||
.regex(/^[a-zA-Z0-9_]+$/),
|
||||
avatar: z.string().url().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updateProfileSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "validation_error", message: "Invalid input", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { name, username, avatar } = parsed.data;
|
||||
|
||||
// Check if username is taken by another user
|
||||
if (username !== session.user.username) {
|
||||
const existingUser = await db.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.id !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "username_taken", message: "This username is already taken" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
const user = await db.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: {
|
||||
name,
|
||||
username,
|
||||
avatar: avatar || null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error("Update profile error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "unauthorized", message: "You must be logged in" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
avatar: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "not_found", message: "User not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(user);
|
||||
} catch (error) {
|
||||
console.error("Get profile error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "server_error", message: "Something went wrong" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
135
src/app/categories/[slug]/page.tsx
Normal file
135
src/app/categories/[slug]/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PromptList } from "@/components/prompts/prompt-list";
|
||||
import { SubscribeButton } from "@/components/categories/subscribe-button";
|
||||
|
||||
interface CategoryPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const category = await db.category.findUnique({
|
||||
where: { slug },
|
||||
select: { name: true, description: true },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return { title: "Category Not Found" };
|
||||
}
|
||||
|
||||
return {
|
||||
title: category.name,
|
||||
description: category.description || `Browse prompts in ${category.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
const { slug } = await params;
|
||||
const session = await auth();
|
||||
const t = await getTranslations();
|
||||
|
||||
const category = await db.category.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
_count: {
|
||||
select: { prompts: true, subscribers: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Check if user is subscribed
|
||||
const isSubscribed = session?.user
|
||||
? await db.categorySubscription.findUnique({
|
||||
where: {
|
||||
userId_categoryId: {
|
||||
userId: session.user.id,
|
||||
categoryId: category.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// Fetch prompts in this category
|
||||
const prompts = await db.prompt.findMany({
|
||||
where: {
|
||||
categoryId: category.id,
|
||||
isPrivate: false,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" className="mb-4 -ml-2" asChild>
|
||||
<Link href="/categories">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
All Categories
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{category.name}</h1>
|
||||
{category.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{category.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-sm text-muted-foreground">
|
||||
<span>{category._count.prompts} prompts</span>
|
||||
<span>•</span>
|
||||
<span>{category._count.subscribers} subscribers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session?.user && (
|
||||
<SubscribeButton
|
||||
categoryId={category.id}
|
||||
categoryName={category.name}
|
||||
initialSubscribed={!!isSubscribed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompts */}
|
||||
<PromptList prompts={prompts} currentPage={1} totalPages={1} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/categories/page.tsx
Normal file
124
src/app/categories/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { FolderOpen, ChevronRight } from "lucide-react";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SubscribeButton } from "@/components/categories/subscribe-button";
|
||||
import { CategoryItem } from "@/components/categories/category-item";
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const t = await getTranslations("categories");
|
||||
const session = await auth();
|
||||
|
||||
// Fetch root categories (no parent) with their children
|
||||
const rootCategories = await db.category.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: { prompts: true },
|
||||
},
|
||||
children: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: { prompts: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get user's subscriptions if logged in
|
||||
const subscriptions = session?.user
|
||||
? await db.categorySubscription.findMany({
|
||||
where: { userId: session.user.id },
|
||||
select: { categoryId: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const subscribedIds = new Set(subscriptions.map((s) => s.categoryId));
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
{rootCategories.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/30">
|
||||
<FolderOpen className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t("noCategories")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{rootCategories.map((category) => (
|
||||
<section key={category.id}>
|
||||
{/* Main Category Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{category.icon && (
|
||||
<span className="text-2xl">{category.icon}</span>
|
||||
)}
|
||||
<div>
|
||||
<Link
|
||||
href={`/categories/${category.slug}`}
|
||||
className="text-lg font-semibold hover:underline flex items-center gap-1"
|
||||
>
|
||||
{category.name}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
{category.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{category.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{category._count.prompts} {t("prompts")}
|
||||
</Badge>
|
||||
{session?.user && (
|
||||
<SubscribeButton
|
||||
categoryId={category.id}
|
||||
categoryName={category.name}
|
||||
initialSubscribed={subscribedIds.has(category.id)}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subcategories Grid */}
|
||||
{category.children.length > 0 ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{category.children.map((child) => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
category={{
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
slug: child.slug,
|
||||
icon: child.icon,
|
||||
promptCount: child._count.prompts,
|
||||
}}
|
||||
isSubscribed={subscribedIds.has(child.id)}
|
||||
showSubscribe={!!session?.user}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground border rounded-lg p-4 bg-muted/20">
|
||||
{t("noSubcategories")}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
230
src/app/globals.css
Normal file
230
src/app/globals.css
Normal file
@@ -0,0 +1,230 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-arabic: var(--font-arabic);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
/* Container utility */
|
||||
.container {
|
||||
@apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Dense text utilities */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme Variants */
|
||||
|
||||
/* Flat theme - minimal shadows, subtle borders */
|
||||
.theme-flat {
|
||||
--shadow-sm: none;
|
||||
--shadow: none;
|
||||
--shadow-md: none;
|
||||
--shadow-lg: none;
|
||||
}
|
||||
|
||||
.theme-flat .border,
|
||||
.theme-flat [class*="border"] {
|
||||
border-color: oklch(0.9 0 0);
|
||||
}
|
||||
|
||||
.dark.theme-flat .border,
|
||||
.dark.theme-flat [class*="border"] {
|
||||
border-color: oklch(0.25 0 0);
|
||||
}
|
||||
|
||||
/* Default theme - balanced shadows and borders */
|
||||
.theme-default {
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Brutal theme - strong borders, no radius, bold shadows */
|
||||
.theme-brutal {
|
||||
--radius: 0 !important;
|
||||
--shadow-sm: 2px 2px 0 0 currentColor;
|
||||
--shadow: 3px 3px 0 0 currentColor;
|
||||
--shadow-md: 4px 4px 0 0 currentColor;
|
||||
--shadow-lg: 6px 6px 0 0 currentColor;
|
||||
}
|
||||
|
||||
.theme-brutal .border,
|
||||
.theme-brutal [class*="border"] {
|
||||
border-width: 2px;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
/* Flat button overrides */
|
||||
.theme-flat button,
|
||||
.theme-flat [role="button"] {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.theme-flat input,
|
||||
.theme-flat select,
|
||||
.theme-flat textarea {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Density Variants */
|
||||
|
||||
/* Compact density - smaller spacing and elements */
|
||||
.density-compact {
|
||||
--spacing-unit: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.density-compact .container {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.density-compact h1 { font-size: 1.5rem; }
|
||||
.density-compact h2 { font-size: 1.25rem; }
|
||||
.density-compact h3 { font-size: 1rem; }
|
||||
|
||||
/* Default density */
|
||||
.density-default {
|
||||
--spacing-unit: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Comfortable density - larger spacing and elements */
|
||||
.density-comfortable {
|
||||
--spacing-unit: 1.25rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.density-comfortable .container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.density-comfortable h1 { font-size: 2.5rem; }
|
||||
.density-comfortable h2 { font-size: 2rem; }
|
||||
.density-comfortable h3 { font-size: 1.5rem; }
|
||||
100
src/app/layout.tsx
Normal file
100
src/app/layout.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Noto_Sans_Arabic } from "next/font/google";
|
||||
import { getMessages, getLocale } from "next-intl/server";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { isRtlLocale } from "@/lib/i18n/config";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const notoSansArabic = Noto_Sans_Arabic({
|
||||
subsets: ["arabic"],
|
||||
variable: "--font-arabic",
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "prompts.chat",
|
||||
description: "Collect, organize, and share AI prompts",
|
||||
};
|
||||
|
||||
const radiusValues = {
|
||||
none: "0",
|
||||
sm: "0.25rem",
|
||||
md: "0.5rem",
|
||||
lg: "0.75rem",
|
||||
};
|
||||
|
||||
function hexToOklch(hex: string): string {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return "oklch(0.5 0.2 260)";
|
||||
|
||||
const r = parseInt(result[1], 16) / 255;
|
||||
const g = parseInt(result[2], 16) / 255;
|
||||
const b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const c = (max - min) * 0.4;
|
||||
|
||||
let h = 0;
|
||||
if (max !== min) {
|
||||
if (max === r) h = ((g - b) / (max - min)) * 60;
|
||||
else if (max === g) h = (2 + (b - r) / (max - min)) * 60;
|
||||
else h = (4 + (r - g) / (max - min)) * 60;
|
||||
}
|
||||
if (h < 0) h += 360;
|
||||
|
||||
return `oklch(${(l * 0.8 + 0.2).toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)})`;
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
const config = await getConfig();
|
||||
const isRtl = isRtlLocale(locale);
|
||||
|
||||
// Calculate theme values server-side
|
||||
const themeClasses = `theme-${config.theme.variant} density-${config.theme.density}`;
|
||||
const primaryOklch = hexToOklch(config.theme.colors.primary);
|
||||
const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(config.theme.colors.primary);
|
||||
const lightness = rgb
|
||||
? 0.2126 * (parseInt(rgb[1], 16) / 255) + 0.7152 * (parseInt(rgb[2], 16) / 255) + 0.0722 * (parseInt(rgb[3], 16) / 255)
|
||||
: 0.5;
|
||||
const foreground = lightness > 0.5 ? "oklch(0.2 0 0)" : "oklch(0.98 0 0)";
|
||||
|
||||
const themeStyles = {
|
||||
"--radius": radiusValues[config.theme.radius],
|
||||
"--primary": primaryOklch,
|
||||
"--primary-foreground": foreground,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const fontClasses = isRtl
|
||||
? `${inter.variable} ${notoSansArabic.variable} font-arabic`
|
||||
: `${inter.variable} font-sans`;
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={isRtl ? "rtl" : "ltr"} suppressHydrationWarning className={themeClasses} style={themeStyles}>
|
||||
<body className={`${fontClasses} antialiased`}>
|
||||
<Providers locale={locale} messages={messages} theme={config.theme} branding={config.branding}>
|
||||
<div className="relative min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
64
src/app/not-found.tsx
Normal file
64
src/app/not-found.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FileQuestion, Home, ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("notFound");
|
||||
|
||||
return (
|
||||
<div className="container flex flex-col items-center justify-center min-h-[60vh] py-12">
|
||||
<div className="text-center space-y-6 max-w-md">
|
||||
{/* Icon */}
|
||||
<div className="mx-auto w-20 h-20 rounded-full bg-muted flex items-center justify-center">
|
||||
<FileQuestion className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Error Code */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-7xl font-bold text-primary">404</h1>
|
||||
<h2 className="text-xl font-semibold">{t("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 pt-4">
|
||||
<Button asChild>
|
||||
<Link href="/">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
{t("goHome")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("goBack")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Helpful Links */}
|
||||
<div className="pt-8 border-t">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{t("helpfulLinks")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm">
|
||||
<Link href="/prompts" className="text-primary hover:underline">
|
||||
{t("browsePrompts")}
|
||||
</Link>
|
||||
<Link href="/categories" className="text-primary hover:underline">
|
||||
{t("categories")}
|
||||
</Link>
|
||||
<Link href="/prompts/new" className="text-primary hover:underline">
|
||||
{t("createPrompt")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
src/app/page.tsx
Normal file
197
src/app/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowRight, Bell, FolderOpen } from "lucide-react";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PromptList } from "@/components/prompts/prompt-list";
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations("feed");
|
||||
const tHomepage = await getTranslations("homepage");
|
||||
const tNav = await getTranslations("nav");
|
||||
const session = await auth();
|
||||
|
||||
// For logged-in users, show subscribed categories feed
|
||||
if (session?.user) {
|
||||
// Get user's subscribed categories
|
||||
const subscriptions = await db.categorySubscription.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const subscribedCategoryIds = subscriptions.map((s) => s.categoryId);
|
||||
|
||||
// Fetch prompts from subscribed categories
|
||||
const promptsRaw = subscribedCategoryIds.length > 0
|
||||
? await db.prompt.findMany({
|
||||
where: {
|
||||
isPrivate: false,
|
||||
categoryId: { in: subscribedCategoryIds },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { votes: true, contributors: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const prompts = promptsRaw.map((p) => ({
|
||||
...p,
|
||||
voteCount: p._count?.votes ?? 0,
|
||||
contributorCount: p._count?.contributors ?? 0,
|
||||
}));
|
||||
|
||||
// Get all categories for subscription
|
||||
const categories = await db.category.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
_count: {
|
||||
select: { prompts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">{t("yourFeed")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("feedDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/prompts">
|
||||
{t("browseAll")}
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Subscribed Categories */}
|
||||
{subscriptions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{subscriptions.map(({ category }) => (
|
||||
<Link key={category.id} href={`/categories/${category.slug}`}>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Bell className="h-3 w-3" />
|
||||
{category.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed */}
|
||||
{prompts.length > 0 ? (
|
||||
<PromptList prompts={prompts} currentPage={1} totalPages={1} />
|
||||
) : (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/30">
|
||||
<FolderOpen className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<h2 className="font-medium mb-1">{t("noPromptsInFeed")}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("subscribeToCategories")}
|
||||
</p>
|
||||
|
||||
{/* Category suggestions */}
|
||||
<div className="flex flex-wrap justify-center gap-2 max-w-md mx-auto">
|
||||
{categories.slice(0, 6).map((category) => (
|
||||
<Link key={category.id} href={`/categories/${category.slug}`}>
|
||||
<Badge variant="outline" className="cursor-pointer hover:bg-accent">
|
||||
{category.name}
|
||||
<span className="ml-1 text-muted-foreground">({category._count.prompts})</span>
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/categories">{t("viewAllCategories")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For non-logged-in users, show landing page
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Hero Section */}
|
||||
<section className="py-12 md:py-20 border-b">
|
||||
<div className="container">
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
|
||||
{tHomepage("heroTitle")}
|
||||
<span className="block text-muted-foreground">{tHomepage("heroSubtitle")}</span>
|
||||
</h1>
|
||||
<p className="mt-4 text-muted-foreground max-w-xl">
|
||||
{tHomepage("heroDescription")}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/prompts">
|
||||
{tHomepage("browsePrompts")}
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/register">{tNav("register")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-12">
|
||||
<div className="container">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-6 rounded-lg border bg-muted/30">
|
||||
<div>
|
||||
<h2 className="font-semibold">{tHomepage("readyToStart")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{tHomepage("freeAndOpen")}</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/register">{tHomepage("createAccount")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/app/prompts/[id]/changes/[changeId]/page.tsx
Normal file
169
src/app/prompts/[id]/changes/[changeId]/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations, getLocale } from "next-intl/server";
|
||||
import { formatDistanceToNow } from "@/lib/date";
|
||||
import { ArrowLeft, Clock, Check, X, FileText } from "lucide-react";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { DiffView } from "@/components/ui/diff-view";
|
||||
import { ChangeRequestActions } from "@/components/prompts/change-request-actions";
|
||||
import { ReopenChangeRequestButton } from "@/components/prompts/reopen-change-request-button";
|
||||
|
||||
interface ChangeRequestPageProps {
|
||||
params: Promise<{ id: string; changeId: string }>;
|
||||
}
|
||||
|
||||
export default async function ChangeRequestPage({ params }: ChangeRequestPageProps) {
|
||||
const session = await auth();
|
||||
const t = await getTranslations("changeRequests");
|
||||
const locale = await getLocale();
|
||||
const { id: promptId, changeId } = await params;
|
||||
|
||||
const changeRequest = await db.changeRequest.findUnique({
|
||||
where: { id: changeId },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
authorId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!changeRequest || changeRequest.prompt.id !== promptId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isPromptOwner = session?.user?.id === changeRequest.prompt.authorId;
|
||||
|
||||
const statusConfig = {
|
||||
PENDING: {
|
||||
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
|
||||
icon: Clock,
|
||||
},
|
||||
APPROVED: {
|
||||
color: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
||||
icon: Check,
|
||||
},
|
||||
REJECTED: {
|
||||
color: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
icon: X,
|
||||
},
|
||||
};
|
||||
|
||||
const StatusIcon = statusConfig[changeRequest.status].icon;
|
||||
const hasTitleChange = changeRequest.proposedTitle && changeRequest.proposedTitle !== changeRequest.originalTitle;
|
||||
|
||||
return (
|
||||
<div className="container max-w-3xl py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" asChild className="mb-4 -ml-2">
|
||||
<Link href={`/prompts/${promptId}`}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5" />
|
||||
{t("backToPrompt")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Title and status */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<Badge className={statusConfig[changeRequest.status].color}>
|
||||
<StatusIcon className="h-3 w-3 mr-1" />
|
||||
{t(changeRequest.status.toLowerCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
<Link
|
||||
href={`/prompts/${promptId}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1.5 mt-1"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{changeRequest.prompt.title}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author and time */}
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={changeRequest.author.avatar || ""} />
|
||||
<AvatarFallback className="text-xs">{changeRequest.author.name?.[0] || changeRequest.author.username[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">
|
||||
<Link href={`/${changeRequest.author.username}`} className="font-medium hover:underline">
|
||||
@{changeRequest.author.username}
|
||||
</Link>
|
||||
<span className="text-muted-foreground"> · {formatDistanceToNow(changeRequest.createdAt, locale)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
{changeRequest.reason && (
|
||||
<div className="mb-6 p-4 bg-muted/30 rounded-lg border">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">{t("reason")}</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{changeRequest.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title change */}
|
||||
{hasTitleChange && (
|
||||
<div className="mb-6 p-4 bg-muted/30 rounded-lg border">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">{t("titleChange")}</p>
|
||||
<div className="text-sm">
|
||||
<span className="text-red-600 dark:text-red-400 line-through">{changeRequest.originalTitle}</span>
|
||||
<span className="text-muted-foreground mx-2">→</span>
|
||||
<span className="text-green-600 dark:text-green-400">{changeRequest.proposedTitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content diff */}
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">{t("contentChanges")}</p>
|
||||
<DiffView
|
||||
original={changeRequest.originalContent}
|
||||
modified={changeRequest.proposedContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Review note (if exists) */}
|
||||
{changeRequest.reviewNote && (
|
||||
<div className="mb-6 p-4 rounded-lg border border-blue-500/20 bg-blue-500/5">
|
||||
<p className="text-xs font-medium text-blue-600 dark:text-blue-400 uppercase tracking-wide mb-2">{t("reviewNote")}</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{changeRequest.reviewNote}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions for prompt owner */}
|
||||
{isPromptOwner && changeRequest.status === "PENDING" && (
|
||||
<div className="pt-4 border-t">
|
||||
<ChangeRequestActions changeRequestId={changeRequest.id} promptId={promptId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reopen button for rejected requests */}
|
||||
{isPromptOwner && changeRequest.status === "REJECTED" && (
|
||||
<div className="pt-4 border-t">
|
||||
<ReopenChangeRequestButton changeRequestId={changeRequest.id} promptId={promptId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/app/prompts/[id]/changes/new/page.tsx
Normal file
77
src/app/prompts/[id]/changes/new/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChangeRequestForm } from "@/components/prompts/change-request-form";
|
||||
|
||||
interface NewChangeRequestPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function NewChangeRequestPage({ params }: NewChangeRequestPageProps) {
|
||||
const session = await auth();
|
||||
const t = await getTranslations("changeRequests");
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
type: true,
|
||||
structuredFormat: true,
|
||||
authorId: true,
|
||||
isPrivate: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Can't create change request for own prompt
|
||||
if (prompt.authorId === session.user.id) {
|
||||
redirect(`/prompts/${id}`);
|
||||
}
|
||||
|
||||
// Can't create change request for private prompt
|
||||
if (prompt.isPrivate) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-3xl py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" asChild className="mb-4 -ml-2">
|
||||
<Link href={`/prompts/${id}`}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5" />
|
||||
{t("backToPrompt")}
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{t("create")}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{prompt.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<ChangeRequestForm
|
||||
promptId={prompt.id}
|
||||
currentContent={prompt.content}
|
||||
currentTitle={prompt.title}
|
||||
promptType={prompt.type}
|
||||
structuredFormat={prompt.structuredFormat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/app/prompts/[id]/edit/page.tsx
Normal file
92
src/app/prompts/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { PromptForm } from "@/components/prompts/prompt-form";
|
||||
|
||||
interface EditPromptPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Edit Prompt",
|
||||
description: "Edit your prompt",
|
||||
};
|
||||
|
||||
export default async function EditPromptPage({ params }: EditPromptPageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
const t = await getTranslations("prompts");
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// Fetch the prompt
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Check if user is the author
|
||||
if (prompt.authorId !== session.user.id) {
|
||||
redirect(`/prompts/${id}`);
|
||||
}
|
||||
|
||||
// Fetch categories and tags for the form
|
||||
const [categories, tags] = await Promise.all([
|
||||
db.category.findMany({
|
||||
orderBy: [{ order: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
parentId: true,
|
||||
},
|
||||
}),
|
||||
db.tag.findMany({ orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
// Transform prompt data for the form
|
||||
const initialData = {
|
||||
title: prompt.title,
|
||||
description: prompt.description || "",
|
||||
content: prompt.content,
|
||||
type: prompt.type as "TEXT" | "IMAGE" | "VIDEO" | "AUDIO" | "STRUCTURED",
|
||||
structuredFormat: (prompt.structuredFormat as "JSON" | "YAML") || "JSON",
|
||||
categoryId: prompt.categoryId || undefined,
|
||||
tagIds: prompt.tags.map((t) => t.tagId),
|
||||
isPrivate: prompt.isPrivate,
|
||||
mediaUrl: prompt.mediaUrl || "",
|
||||
requiresMediaUpload: prompt.requiresMediaUpload,
|
||||
requiredMediaType: (prompt.requiredMediaType as "IMAGE" | "VIDEO" | "DOCUMENT") || "IMAGE",
|
||||
requiredMediaCount: prompt.requiredMediaCount || 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-6 max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold">{t("edit")}</h1>
|
||||
<p className="text-sm text-muted-foreground">Update your prompt details</p>
|
||||
</div>
|
||||
<PromptForm
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
initialData={initialData}
|
||||
promptId={id}
|
||||
mode="edit"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
src/app/prompts/[id]/opengraph-image.tsx
Normal file
207
src/app/prompts/[id]/opengraph-image.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { db } from "@/lib/db";
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
export const alt = "Prompt Preview";
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default async function OGImage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const config = await getConfig();
|
||||
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
select: {
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#0a0a0a",
|
||||
color: "#fff",
|
||||
fontSize: 48,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Prompt Not Found
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
|
||||
const truncatedContent = prompt.content.length > 280
|
||||
? prompt.content.slice(0, 280) + "..."
|
||||
: prompt.content;
|
||||
|
||||
const isImagePrompt = prompt.type === "IMAGE" && prompt.mediaUrl;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
backgroundColor: "#09090b",
|
||||
padding: 56,
|
||||
}}
|
||||
>
|
||||
{/* Left Content */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
marginRight: isImagePrompt ? 48 : 0,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22, fontWeight: 500, color: "#52525b" }}>
|
||||
{config.branding.name}
|
||||
</span>
|
||||
|
||||
{prompt.category && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: "#71717a",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{prompt.category.icon && <span>{prompt.category.icon}</span>}
|
||||
<span>{prompt.category.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 44,
|
||||
fontWeight: 600,
|
||||
color: "#fafafa",
|
||||
lineHeight: 1.25,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{prompt.title}
|
||||
</div>
|
||||
|
||||
{/* Content Preview */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: "#a1a1aa",
|
||||
lineHeight: 1.5,
|
||||
flex: 1,
|
||||
backgroundColor: "#18181b",
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
border: "1px solid #27272a",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{truncatedContent}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{prompt.author.avatar ? (
|
||||
<img
|
||||
src={prompt.author.avatar}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ borderRadius: 20 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#27272a",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#a1a1aa",
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{(prompt.author.name || prompt.author.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span style={{ color: "#fafafa", fontSize: 18, fontWeight: 500 }}>
|
||||
{prompt.author.name || prompt.author.username}
|
||||
</span>
|
||||
<span style={{ color: "#71717a", fontSize: 14 }}>
|
||||
@{prompt.author.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Preview (for image prompts) */}
|
||||
{isImagePrompt && (
|
||||
<img
|
||||
src={prompt.mediaUrl!}
|
||||
width={340}
|
||||
height={518}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
border: "1px solid #27272a",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
522
src/app/prompts/[id]/page.tsx
Normal file
522
src/app/prompts/[id]/page.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations, getLocale } from "next-intl/server";
|
||||
import { formatDistanceToNow } from "@/lib/date";
|
||||
import { Calendar, Clock, Copy, Share2, Edit, History, GitPullRequest, Check, X, Users, ImageIcon, Video, FileText } from "lucide-react";
|
||||
import { ShareDropdown } from "@/components/prompts/share-dropdown";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { CopyButton } from "@/components/prompts/copy-button";
|
||||
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
|
||||
import { UpvoteButton } from "@/components/prompts/upvote-button";
|
||||
import { AddVersionForm } from "@/components/prompts/add-version-form";
|
||||
import { DeleteVersionButton } from "@/components/prompts/delete-version-button";
|
||||
import { VersionCompareModal } from "@/components/prompts/version-compare-modal";
|
||||
import { VersionCompareButton } from "@/components/prompts/version-compare-button";
|
||||
import { CodeView } from "@/components/ui/code-view";
|
||||
|
||||
interface PromptPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PromptPageProps): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id },
|
||||
select: { title: true, description: true },
|
||||
});
|
||||
|
||||
if (!prompt) {
|
||||
return { title: "Prompt Not Found" };
|
||||
}
|
||||
|
||||
return {
|
||||
title: prompt.title,
|
||||
description: prompt.description || `View the prompt: ${prompt.title}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PromptPage({ params }: PromptPageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
const t = await getTranslations("prompts");
|
||||
const locale = await getLocale();
|
||||
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
orderBy: { version: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
content: true,
|
||||
changeNote: true,
|
||||
createdAt: true,
|
||||
author: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { votes: true },
|
||||
},
|
||||
contributors: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if user has voted
|
||||
const userVote = session?.user
|
||||
? await db.promptVote.findUnique({
|
||||
where: {
|
||||
userId_promptId: {
|
||||
userId: session.user.id,
|
||||
promptId: id,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!prompt) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Check if user can view private prompt
|
||||
if (prompt.isPrivate && prompt.authorId !== session?.user?.id) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isOwner = session?.user?.id === prompt.authorId;
|
||||
const voteCount = prompt._count?.votes ?? 0;
|
||||
const hasVoted = !!userVote;
|
||||
|
||||
// Fetch change requests for this prompt
|
||||
const changeRequests = await db.changeRequest.findMany({
|
||||
where: { promptId: id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pendingCount = changeRequests.filter((cr) => cr.status === "PENDING").length;
|
||||
const tChanges = await getTranslations("changeRequests");
|
||||
|
||||
const statusColors = {
|
||||
PENDING: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
|
||||
APPROVED: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
||||
REJECTED: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
};
|
||||
|
||||
const statusIcons = {
|
||||
PENDING: Clock,
|
||||
APPROVED: Check,
|
||||
REJECTED: X,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold">{prompt.title}</h1>
|
||||
{prompt.isPrivate && (
|
||||
<Badge variant="secondary">{t("promptPrivate")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{prompt.description && (
|
||||
<p className="text-muted-foreground">{prompt.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<History className="h-4 w-4" />
|
||||
<span>{prompt.versions.length > 0 ? prompt.versions[0].version : 1} {prompt.versions.length === 1 ? t("version") : t("versionsCount")}</span>
|
||||
</div>
|
||||
{prompt.contributors.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{prompt.contributors.length + 1} {t("contributors")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<UpvoteButton
|
||||
promptId={prompt.id}
|
||||
initialVoted={hasVoted}
|
||||
initialCount={voteCount}
|
||||
isLoggedIn={!!session?.user}
|
||||
showLabel
|
||||
/>
|
||||
<ShareDropdown title={prompt.title} />
|
||||
{isOwner && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/prompts/${id}/edit`}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
{t("edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
<Link href={`/@${prompt.author.username}`} title={`@${prompt.author.username}`}>
|
||||
<Avatar className="h-6 w-6 border-2 border-background">
|
||||
<AvatarImage src={prompt.author.avatar || undefined} />
|
||||
<AvatarFallback className="text-xs">{prompt.author.name?.charAt(0) || prompt.author.username.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
{prompt.contributors.map((contributor) => (
|
||||
<Link key={contributor.id} href={`/@${contributor.username}`} title={`@${contributor.username}`}>
|
||||
<Avatar className="h-6 w-6 border-2 border-background">
|
||||
<AvatarImage src={contributor.avatar || undefined} />
|
||||
<AvatarFallback className="text-xs">{contributor.name?.charAt(0) || contributor.username.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{prompt.contributors.length > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default">
|
||||
@{prompt.author.username} +{prompt.contributors.length}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="p-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium mb-1.5">Contributors</div>
|
||||
{prompt.contributors.map((contributor) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
href={`/${contributor.username}`}
|
||||
className="flex items-center gap-2 hover:underline rounded px-1 py-0.5 -mx-1"
|
||||
>
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarImage src={contributor.avatar || undefined} />
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{contributor.name?.charAt(0) || contributor.username.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs">@{contributor.username}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>@{prompt.author.username}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{formatDistanceToNow(prompt.createdAt, locale)}</span>
|
||||
</div>
|
||||
{prompt.category && (
|
||||
<Link href={`/categories/${prompt.category.slug}`}>
|
||||
<Badge variant="outline">{prompt.category.name}</Badge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{prompt.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{prompt.tags.map(({ tag }) => (
|
||||
<Link key={tag.id} href={`/tags/${tag.slug}`}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
style={{ backgroundColor: tag.color + "20", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Tabs */}
|
||||
<Tabs defaultValue="content">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="content">{t("promptContent")}</TabsTrigger>
|
||||
<TabsTrigger value="versions">
|
||||
<History className="h-4 w-4 mr-1" />
|
||||
{t("versions")}
|
||||
</TabsTrigger>
|
||||
{changeRequests.length > 0 && (
|
||||
<TabsTrigger value="changes" className="gap-1">
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
{t("changeRequests")}
|
||||
{pendingCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-1 h-5 min-w-5 px-1 text-xs">
|
||||
{pendingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{!isOwner && session?.user && (
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/prompts/${id}/changes/new`}>
|
||||
<GitPullRequest className="h-4 w-4 mr-1.5" />
|
||||
{t("createChangeRequest")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="content" className="space-y-4 mt-0">
|
||||
{/* Media Preview (for image/video prompts) */}
|
||||
{prompt.mediaUrl && (
|
||||
<div className="rounded-lg overflow-hidden border bg-muted/30">
|
||||
{prompt.type === "VIDEO" ? (
|
||||
<video
|
||||
src={prompt.mediaUrl}
|
||||
controls
|
||||
className="w-full max-h-[500px] object-contain block"
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={prompt.mediaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={prompt.mediaUrl}
|
||||
alt={prompt.title}
|
||||
className="w-full max-h-[500px] object-contain block"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt Text Content */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold">{t("promptContent")}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{prompt.requiresMediaUpload && prompt.requiredMediaType && prompt.requiredMediaCount && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400">
|
||||
{prompt.requiredMediaType === "IMAGE" && <ImageIcon className="h-3.5 w-3.5" />}
|
||||
{prompt.requiredMediaType === "VIDEO" && <Video className="h-3.5 w-3.5" />}
|
||||
{prompt.requiredMediaType === "DOCUMENT" && <FileText className="h-3.5 w-3.5" />}
|
||||
<span className="text-xs font-medium">
|
||||
{prompt.requiredMediaType === "IMAGE"
|
||||
? t("requiresImage", { count: prompt.requiredMediaCount })
|
||||
: prompt.requiredMediaType === "VIDEO"
|
||||
? t("requiresVideo", { count: prompt.requiredMediaCount })
|
||||
: t("requiresDocument", { count: prompt.requiredMediaCount })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<RunPromptButton content={prompt.content} />
|
||||
<CopyButton content={prompt.content} />
|
||||
</div>
|
||||
</div>
|
||||
{prompt.type === "STRUCTURED" ? (
|
||||
<CodeView
|
||||
content={prompt.content}
|
||||
language={(prompt.structuredFormat?.toLowerCase() as "json" | "yaml") || "json"}
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm bg-muted p-4 rounded-lg font-mono border">
|
||||
{prompt.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="versions" className="mt-0">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold">{t("versionHistory")}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<VersionCompareModal
|
||||
versions={prompt.versions}
|
||||
currentContent={prompt.content}
|
||||
promptType={prompt.type}
|
||||
structuredFormat={prompt.structuredFormat}
|
||||
/>
|
||||
{isOwner && (
|
||||
<AddVersionForm promptId={prompt.id} currentContent={prompt.content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{prompt.versions.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center">{t("noVersions")}</p>
|
||||
) : (
|
||||
<div className="divide-y border rounded-lg">
|
||||
{prompt.versions.map((version, index) => {
|
||||
const isLatestVersion = index === 0;
|
||||
return (
|
||||
<div
|
||||
key={version.id}
|
||||
className="px-4 py-3 flex items-start gap-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">v{version.version}</span>
|
||||
{isLatestVersion && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
{t("currentVersion")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(version.createdAt, locale)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
by @{version.author.username}
|
||||
</span>
|
||||
</div>
|
||||
{version.changeNote && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{version.changeNote}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isLatestVersion && (
|
||||
<VersionCompareButton
|
||||
versionContent={version.content}
|
||||
versionNumber={version.version}
|
||||
currentContent={prompt.content}
|
||||
promptType={prompt.type}
|
||||
structuredFormat={prompt.structuredFormat}
|
||||
/>
|
||||
)}
|
||||
{isOwner && !isLatestVersion && (
|
||||
<DeleteVersionButton
|
||||
promptId={prompt.id}
|
||||
versionId={version.id}
|
||||
versionNumber={version.version}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{changeRequests.length > 0 && (
|
||||
<TabsContent value="changes" className="mt-0">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-base font-semibold">{t("changeRequests")}</h3>
|
||||
</div>
|
||||
<div className="divide-y border rounded-lg">
|
||||
{changeRequests.map((cr) => {
|
||||
const StatusIcon = statusIcons[cr.status];
|
||||
const hasTitleChange = cr.proposedTitle && cr.proposedTitle !== cr.originalTitle;
|
||||
return (
|
||||
<Link
|
||||
key={cr.id}
|
||||
href={`/prompts/${id}/changes/${cr.id}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/50 transition-colors first:rounded-t-lg last:rounded-b-lg"
|
||||
>
|
||||
<div className={`p-1.5 rounded-full shrink-0 ${
|
||||
cr.status === "PENDING"
|
||||
? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"
|
||||
: cr.status === "APPROVED"
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
<StatusIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{hasTitleChange ? (
|
||||
<>
|
||||
<span className="line-through text-muted-foreground">{cr.originalTitle}</span>
|
||||
{" → "}
|
||||
<span>{cr.proposedTitle}</span>
|
||||
</>
|
||||
) : (
|
||||
tChanges("contentChanges")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{cr.reason && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
||||
{cr.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage src={cr.author.avatar || undefined} />
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{cr.author.name?.[0] || cr.author.username[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden sm:inline">@{cr.author.username}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{formatDistanceToNow(cr.createdAt, locale)}
|
||||
</span>
|
||||
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 h-5 ${statusColors[cr.status]}`}>
|
||||
{tChanges(cr.status.toLowerCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/app/prompts/new/page.tsx
Normal file
43
src/app/prompts/new/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { PromptForm } from "@/components/prompts/prompt-form";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Prompt",
|
||||
description: "Create a new prompt",
|
||||
};
|
||||
|
||||
export default async function NewPromptPage() {
|
||||
const session = await auth();
|
||||
const t = await getTranslations("prompts");
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// Fetch categories for the form (with parent info for nesting)
|
||||
const categories = await db.category.findMany({
|
||||
orderBy: [{ order: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch tags for the form
|
||||
const tags = await db.tag.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container max-w-3xl py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">{t("create")}</h1>
|
||||
<PromptForm categories={categories} tags={tags} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
src/app/prompts/page.tsx
Normal file
172
src/app/prompts/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InfinitePromptList } from "@/components/prompts/infinite-prompt-list";
|
||||
import { PromptFilters } from "@/components/prompts/prompt-filters";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Prompts",
|
||||
description: "Browse and discover AI prompts",
|
||||
};
|
||||
|
||||
interface PromptsPageProps {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
type?: string;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
sort?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function PromptsPage({ searchParams }: PromptsPageProps) {
|
||||
const t = await getTranslations("prompts");
|
||||
const tSearch = await getTranslations("search");
|
||||
const params = await searchParams;
|
||||
|
||||
const perPage = 12;
|
||||
|
||||
// Build where clause based on filters
|
||||
const where: Record<string, unknown> = {
|
||||
isPrivate: false,
|
||||
};
|
||||
|
||||
if (params.q) {
|
||||
where.OR = [
|
||||
{ title: { contains: params.q, mode: "insensitive" } },
|
||||
{ content: { contains: params.q, mode: "insensitive" } },
|
||||
{ description: { contains: params.q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
if (params.type) {
|
||||
where.type = params.type;
|
||||
}
|
||||
|
||||
if (params.category) {
|
||||
where.categoryId = params.category;
|
||||
}
|
||||
|
||||
if (params.tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
tag: {
|
||||
slug: params.tag,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build order by clause
|
||||
const isUpvoteSort = params.sort === "upvotes";
|
||||
let orderBy: any = { createdAt: "desc" };
|
||||
if (params.sort === "oldest") {
|
||||
orderBy = { createdAt: "asc" };
|
||||
} else if (isUpvoteSort) {
|
||||
// Sort by vote count descending
|
||||
orderBy = { votes: { _count: "desc" } };
|
||||
}
|
||||
|
||||
// Fetch initial prompts (first page)
|
||||
const [promptsRaw, total] = await Promise.all([
|
||||
db.prompt.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: 0,
|
||||
take: perPage,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { votes: true, contributors: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.prompt.count({ where }),
|
||||
]);
|
||||
|
||||
// Transform to include voteCount and contributorCount
|
||||
const prompts = promptsRaw.map((p) => ({
|
||||
...p,
|
||||
voteCount: p._count.votes,
|
||||
contributorCount: p._count.contributors,
|
||||
}));
|
||||
|
||||
// Fetch categories for filter (with parent info for nesting)
|
||||
const categories = await db.category.findMany({
|
||||
orderBy: [{ order: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch tags for filter
|
||||
const tags = await db.tag.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h1 className="text-lg font-semibold">{t("title")}</h1>
|
||||
<span className="text-xs text-muted-foreground">{tSearch("found", { count: total })}</span>
|
||||
</div>
|
||||
<Button size="sm" className="h-8 text-xs" asChild>
|
||||
<Link href="/prompts/new">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{t("create")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
<aside className="w-full lg:w-56 shrink-0">
|
||||
<PromptFilters
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
currentFilters={params}
|
||||
/>
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0">
|
||||
<InfinitePromptList
|
||||
initialPrompts={prompts}
|
||||
initialTotal={total}
|
||||
filters={{
|
||||
q: params.q,
|
||||
type: params.type,
|
||||
category: params.category,
|
||||
tag: params.tag,
|
||||
sort: params.sort,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/app/settings/page.tsx
Normal file
42
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { ProfileForm } from "@/components/settings/profile-form";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth();
|
||||
const t = await getTranslations("settings");
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-2xl py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProfileForm user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
src/app/tags/[slug]/page.tsx
Normal file
183
src/app/tags/[slug]/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Tag } from "lucide-react";
|
||||
import { db } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PromptCard } from "@/components/prompts/prompt-card";
|
||||
|
||||
interface TagPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: TagPageProps) {
|
||||
const { slug } = await params;
|
||||
const tag = await db.tag.findUnique({
|
||||
where: { slug },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
if (!tag) return { title: "Tag Not Found" };
|
||||
|
||||
return {
|
||||
title: `${tag.name} - Tags`,
|
||||
description: `Browse prompts tagged with ${tag.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TagPage({ params, searchParams }: TagPageProps) {
|
||||
const { slug } = await params;
|
||||
const { page: pageParam } = await searchParams;
|
||||
const session = await auth();
|
||||
const t = await getTranslations("tags");
|
||||
const tPrompts = await getTranslations("prompts");
|
||||
|
||||
const tag = await db.tag.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const page = Math.max(1, parseInt(pageParam || "1"));
|
||||
const perPage = 12;
|
||||
|
||||
// Build where clause
|
||||
const where = {
|
||||
tags: {
|
||||
some: { tagId: tag.id },
|
||||
},
|
||||
OR: session?.user
|
||||
? [{ isPrivate: false }, { authorId: session.user.id }]
|
||||
: [{ isPrivate: false }],
|
||||
};
|
||||
|
||||
// Fetch prompts with this tag
|
||||
const [promptsRaw, total] = await Promise.all([
|
||||
db.prompt.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { votes: true, contributors: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.prompt.count({ where }),
|
||||
]);
|
||||
|
||||
const prompts = promptsRaw.map((p) => ({
|
||||
...p,
|
||||
voteCount: p._count.votes,
|
||||
contributorCount: p._count.contributors,
|
||||
}));
|
||||
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" asChild className="mb-4 -ml-2">
|
||||
<Link href="/tags">
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5" />
|
||||
{t("allTags")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">{tag.name}</h1>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{total} {t("prompts")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompts Grid */}
|
||||
{prompts.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/30">
|
||||
<Tag className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tPrompts("noPrompts")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr">
|
||||
{prompts.map((prompt) => (
|
||||
<PromptCard key={prompt.id} prompt={prompt} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={page <= 1}
|
||||
asChild={page > 1}
|
||||
>
|
||||
{page > 1 ? (
|
||||
<Link href={`/tags/${slug}?page=${page - 1}`}>
|
||||
{tPrompts("previous")}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{tPrompts("previous")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={page >= totalPages}
|
||||
asChild={page < totalPages}
|
||||
>
|
||||
{page < totalPages ? (
|
||||
<Link href={`/tags/${slug}?page=${page + 1}`}>
|
||||
{tPrompts("next")}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{tPrompts("next")}</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/app/tags/page.tsx
Normal file
63
src/app/tags/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Tag } from "lucide-react";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export default async function TagsPage() {
|
||||
const t = await getTranslations("tags");
|
||||
|
||||
// Fetch all tags with prompt counts, ordered by popularity
|
||||
const tags = await db.tag.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { prompts: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
prompts: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/30">
|
||||
<Tag className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t("noTags")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/tags/${tag.slug}`}
|
||||
className="group inline-flex items-center gap-2 px-3 py-1.5 rounded-full border transition-colors hover:border-foreground/30"
|
||||
style={{
|
||||
backgroundColor: tag.color + "10",
|
||||
borderColor: tag.color + "30",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium group-hover:underline">
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tag._count.prompts}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
src/components/admin/categories-table.tsx
Normal file
364
src/components/admin/categories-table.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MoreHorizontal, Plus, Pencil, Trash2, ChevronRight } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
order: number;
|
||||
parentId: string | null;
|
||||
parent: { id: string; name: string } | null;
|
||||
children?: Category[];
|
||||
_count: {
|
||||
prompts: number;
|
||||
children: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CategoriesTableProps {
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export function CategoriesTable({ categories }: CategoriesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("admin.categories");
|
||||
const [editCategory, setEditCategory] = useState<Category | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({ name: "", slug: "", description: "", icon: "", parentId: "" });
|
||||
|
||||
// Get only root categories (no parent) for parent selection
|
||||
const rootCategories = useMemo(() =>
|
||||
categories.filter(c => !c.parentId),
|
||||
[categories]
|
||||
);
|
||||
|
||||
// Build hierarchical list for display (parents first, then children indented)
|
||||
const hierarchicalCategories = useMemo(() => {
|
||||
const result: (Category & { level: number })[] = [];
|
||||
|
||||
// Add root categories and their children
|
||||
rootCategories.forEach(parent => {
|
||||
result.push({ ...parent, level: 0 });
|
||||
const children = categories.filter(c => c.parentId === parent.id);
|
||||
children.forEach(child => {
|
||||
result.push({ ...child, level: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [categories, rootCategories]);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setFormData({ name: "", slug: "", description: "", icon: "", parentId: "" });
|
||||
setIsCreating(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (category: Category) => {
|
||||
setFormData({
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description || "",
|
||||
icon: category.icon || "",
|
||||
parentId: category.parentId || "",
|
||||
});
|
||||
setEditCategory(category);
|
||||
};
|
||||
|
||||
// Filter out invalid parent options (can't be own parent or child of self)
|
||||
const getValidParentOptions = () => {
|
||||
if (!editCategory) return rootCategories;
|
||||
// When editing, exclude self and any category that has this as parent
|
||||
return rootCategories.filter(c =>
|
||||
c.id !== editCategory.id && c.parentId !== editCategory.id
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = editCategory
|
||||
? `/api/admin/categories/${editCategory.id}`
|
||||
: "/api/admin/categories";
|
||||
const method = editCategory ? "PATCH" : "POST";
|
||||
|
||||
const payload = {
|
||||
...formData,
|
||||
parentId: formData.parentId || null, // Convert empty string to null
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save");
|
||||
|
||||
toast.success(editCategory ? t("updated") : t("created"));
|
||||
router.refresh();
|
||||
setEditCategory(null);
|
||||
setIsCreating(false);
|
||||
} catch {
|
||||
toast.error(t("saveFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/categories/${deleteId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
|
||||
toast.success(t("deleted"));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("deleteFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("slug")}</TableHead>
|
||||
<TableHead>{t("parent")}</TableHead>
|
||||
<TableHead className="text-center">{t("prompts")}</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hierarchicalCategories.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
{t("noCategories")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
hierarchicalCategories.map((category) => (
|
||||
<TableRow key={category.id} className={category.level > 0 ? "bg-muted/30" : ""}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2" style={{ paddingLeft: category.level * 24 }}>
|
||||
{category.level > 0 && (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{category.icon && <span>{category.icon}</span>}
|
||||
<span className={category.level === 0 ? "font-medium" : ""}>{category.name}</span>
|
||||
{category._count.children > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{category._count.children} {t("subcategories")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{category.slug}</TableCell>
|
||||
<TableCell>
|
||||
{category.parent ? (
|
||||
<Badge variant="outline">{category.parent.name}</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="bg-primary/10 text-primary hover:bg-primary/20">
|
||||
{t("rootCategory")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{category._count.prompts}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(category)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
{t("edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteId(category.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isCreating || !!editCategory} onOpenChange={() => { setIsCreating(false); setEditCategory(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editCategory ? t("editTitle") : t("createTitle")}</DialogTitle>
|
||||
<DialogDescription>{editCategory ? t("editDescription") : t("createDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="slug">{t("slug")}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="parentId">{t("parentCategory")}</Label>
|
||||
<Select
|
||||
value={formData.parentId}
|
||||
onValueChange={(value) => setFormData({ ...formData, parentId: value === "none" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectParent")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t("noParent")}</SelectItem>
|
||||
{getValidParentOptions().map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.icon && <span className="mr-2">{cat.icon}</span>}
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t("parentHelp")}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">{t("descriptionLabel")}</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="icon">{t("icon")}</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
value={formData.icon}
|
||||
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
|
||||
placeholder="📁"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setIsCreating(false); setEditCategory(null); }}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !formData.name || !formData.slug}>
|
||||
{editCategory ? t("save") : t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
284
src/components/admin/tags-table.tsx
Normal file
284
src/components/admin/tags-table.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MoreHorizontal, Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string;
|
||||
_count: {
|
||||
prompts: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TagsTableProps {
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export function TagsTable({ tags }: TagsTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("admin.tags");
|
||||
const [editTag, setEditTag] = useState<Tag | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({ name: "", slug: "", color: "#6366f1" });
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setFormData({ name: "", slug: "", color: "#6366f1" });
|
||||
setIsCreating(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (tag: Tag) => {
|
||||
setFormData({
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
color: tag.color,
|
||||
});
|
||||
setEditTag(tag);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = editTag ? `/api/admin/tags/${editTag.id}` : "/api/admin/tags";
|
||||
const method = editTag ? "PATCH" : "POST";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save");
|
||||
|
||||
toast.success(editTag ? t("updated") : t("created"));
|
||||
router.refresh();
|
||||
setEditTag(null);
|
||||
setIsCreating(false);
|
||||
} catch {
|
||||
toast.error(t("saveFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/tags/${deleteId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
|
||||
toast.success(t("deleted"));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("deleteFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("slug")}</TableHead>
|
||||
<TableHead>{t("color")}</TableHead>
|
||||
<TableHead className="text-center">{t("prompts")}</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tags.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
{t("noTags")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium"
|
||||
style={{ backgroundColor: tag.color + "20", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{tag.slug}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{tag.color}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{tag._count.prompts}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(tag)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
{t("edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteId(tag.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={isCreating || !!editTag} onOpenChange={() => { setIsCreating(false); setEditTag(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTag ? t("editTitle") : t("createTitle")}</DialogTitle>
|
||||
<DialogDescription>{editTag ? t("editDescription") : t("createDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="slug">{t("slug")}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="color">{t("color")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||
className="w-12 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={formData.color}
|
||||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||
placeholder="#6366f1"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setIsCreating(false); setEditTag(null); }}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !formData.name || !formData.slug}>
|
||||
{editTag ? t("save") : t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
205
src/components/admin/users-table.tsx
Normal file
205
src/components/admin/users-table.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { formatDistanceToNow } from "@/lib/date";
|
||||
import { MoreHorizontal, Shield, User, Trash2 } 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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
role: "ADMIN" | "USER";
|
||||
createdAt: Date;
|
||||
_count: {
|
||||
prompts: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UsersTableProps {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export function UsersTable({ users }: UsersTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("admin.users");
|
||||
const locale = useLocale();
|
||||
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: "ADMIN" | "USER") => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to update role");
|
||||
|
||||
toast.success(t("roleUpdated"));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("roleUpdateFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteUserId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${deleteUserId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Failed to delete user");
|
||||
|
||||
toast.success(t("deleted"));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("deleteFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("user")}</TableHead>
|
||||
<TableHead>{t("email")}</TableHead>
|
||||
<TableHead>{t("role")}</TableHead>
|
||||
<TableHead className="text-center">{t("prompts")}</TableHead>
|
||||
<TableHead>{t("joined")}</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
{user.name?.charAt(0) || user.username.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{user.name || user.username}</div>
|
||||
<div className="text-xs text-muted-foreground">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.role === "ADMIN" ? "default" : "secondary"}>
|
||||
{user.role === "ADMIN" ? <Shield className="h-3 w-3 mr-1" /> : <User className="h-3 w-3 mr-1" />}
|
||||
{user.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{user._count.prompts}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDistanceToNow(user.createdAt, locale)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{user.role === "USER" ? (
|
||||
<DropdownMenuItem onClick={() => handleRoleChange(user.id, "ADMIN")}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
{t("makeAdmin")}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => handleRoleChange(user.id, "USER")}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
{t("removeAdmin")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={!!deleteUserId} onOpenChange={() => setDeleteUserId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user