Initialize v2 work

This commit is contained in:
Fatih Kadir Akın
2025-12-10 15:41:23 +03:00
parent 728789c54c
commit 85a5f3bed7
181 changed files with 28216 additions and 9750 deletions

View File

@@ -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
View 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
View File

@@ -1,3 +1 @@
# These are supported funding model platforms
github: [f]

View File

@@ -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.

View File

@@ -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.'
});

View File

@@ -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'
});
}

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -1 +0,0 @@
.cursorrules

49
Dockerfile Normal file
View 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"]

View File

@@ -1,4 +0,0 @@
source "https://rubygems.org"
gem "jekyll"
gem "github-pages", group: :jekyll_plugins

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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&#10;styles/main.css&#10;scripts/app.js&#10;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">
&lt;iframe src="..."&gt;&lt;/iframe&gt;
</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
View 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
View 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
View 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:

View File

@@ -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();
}
}
};

View File

@@ -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;
}
}

View File

@@ -1,6 +0,0 @@
---
layout: embed-preview
title: "Prompt Viewer"
permalink: /embed-preview/
mode: viewer
---

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -1,5 +0,0 @@
---
layout: embed
title: "Prompt Designer"
permalink: /embed/
---

18
eslint.config.mjs Normal file
View 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
View 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
View 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
View 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
View 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
View 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ıı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
View 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
View 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

File diff suppressed because it is too large Load Diff

71
package.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

16
prisma.config.ts Normal file
View 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"),
},
});

View 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;

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

1522
script.js

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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>

View File

@@ -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"]
}
]

View File

@@ -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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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 });
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

230
src/app/globals.css Normal file
View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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