This commit is contained in:
Fovway 2025-11-12 23:53:05 +07:00
parent d8c8623580
commit 1841d8c356
6 changed files with 799 additions and 10 deletions

30
.kiro/steering/product.md Normal file
View File

@ -0,0 +1,30 @@
---
inclusion: always
---
# Product Overview
Denis Shulepov Gay Quotes Generator - an automated quote generation and display system that creates LGBTQ+ pride quotes in Russian using AI.
## Core Features
- Automated hourly quote generation using OpenRouter API
- Web interface displaying quotes with profile photo
- User engagement through emoji reactions
- Quote persistence in JSON format
- REST API for quote management
## Target Audience
Russian-speaking LGBTQ+ community members seeking bold, confident, and celebratory content about gay pride and culture.
## Content Guidelines
Quotes should be:
- Short and punchy (1-2 sentences max)
- Direct, crude and sexually explicit
- Dark humor with brutal honesty
- Provocative and shocking
- In the style of tough-guy action movie quotes
- In Russian language
- Unapologetically raw and edgy

View File

@ -0,0 +1,61 @@
---
inclusion: always
---
# Project Structure
## Directory Layout
```
/
├── src/
│ └── index.js # Main application entry point
├── public/
│ ├── index.html # Frontend web interface
│ └── photo_*.jpg # Profile photo asset
├── quotes/
│ └── quotes.json # Generated quotes storage (gitignored)
├── .env # Environment variables (gitignored)
├── .env.example # Environment template
└── package.json # Dependencies and scripts
```
## Architecture
**Backend (src/index.js)**
- Express server with CORS enabled
- Static file serving from `public/`
- Cron job for hourly quote generation
- REST API endpoints for quote operations
**Frontend (public/index.html)**
- Single-page application
- Vanilla JavaScript (no framework)
- Inline CSS with gradient theme
- Auto-refresh every 60 seconds
## API Endpoints
- `GET /api/quotes` - Retrieve all quotes (reversed order)
- `POST /api/generate` - Manually trigger quote generation
- `POST /api/quotes/:index/react` - Add emoji reaction to quote
- `POST /api/quotes/:index/comment` - Add comment to quote (implemented but not used in UI)
## Data Model
Quote object structure:
```json
{
"timestamp": "ISO 8601 date string",
"quote": "Quote text in Russian",
"reactions": { "emoji": count },
"comments": [{ "id", "author", "text", "timestamp" }]
}
```
## Conventions
- CommonJS module system (require/module.exports)
- Synchronous file operations for data persistence
- Error logging to console
- Process exits on missing API key

48
.kiro/steering/tech.md Normal file
View File

@ -0,0 +1,48 @@
---
inclusion: always
---
# Technology Stack
## Runtime & Language
- Node.js with CommonJS modules
- JavaScript (no TypeScript)
## Core Dependencies
- **express** - Web server framework
- **axios** - HTTP client for API calls
- **node-cron** - Scheduled task execution
- **dotenv** - Environment variable management
- **cors** - Cross-origin resource sharing
## External Services
- OpenRouter API for AI quote generation
- Configurable AI model (default: gpt-3.5-turbo)
## Common Commands
```bash
# Install dependencies
npm install
# Start the server
npm start
# Server runs on port 3000 by default (configurable via PORT env var)
```
## Environment Configuration
Required environment variables in `.env`:
- `OPENROUTER_API_KEY` - API key for OpenRouter service
- `MODEL` - (optional) AI model to use, defaults to gpt-3.5-turbo
- `PORT` - (optional) Server port, defaults to 3000
## Data Storage
- File-based JSON storage in `quotes/quotes.json`
- No database required
- Quotes directory auto-created on startup

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

View File

@ -120,6 +120,309 @@
font-weight: 600; font-weight: 600;
} }
.reactions-container {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.reaction-btn {
background: #f5f5f5;
border: 2px solid transparent;
padding: 8px 15px;
border-radius: 20px;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 5px;
}
.reaction-btn:hover {
background: #667eea;
border-color: #667eea;
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.reaction-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.reaction-count {
font-size: 0.9rem;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.add-reaction-btn {
background: white;
border: 2px dashed #ccc;
padding: 8px 15px;
border-radius: 20px;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s;
}
.add-reaction-btn:hover {
border-color: #667eea;
background: #f8f9ff;
}
.emoji-picker-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
animation: fadeIn 0.2s;
padding: 20px;
overflow-y: auto;
}
.emoji-picker-overlay.show {
display: flex;
justify-content: center;
align-items: center;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.emoji-picker {
background: white;
border-radius: 20px;
padding: 25px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
max-width: 400px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.3s;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.emoji-picker-title {
font-size: 1.3rem;
font-weight: 600;
color: #333;
margin-bottom: 20px;
text-align: center;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 15px;
}
.emoji-option {
background: #f5f5f5;
border: 2px solid transparent;
padding: 15px;
border-radius: 12px;
font-size: 2rem;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.emoji-option:hover {
background: #667eea;
transform: scale(1.15);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.emoji-picker-close {
width: 100%;
background: #f5f5f5;
color: #666;
padding: 12px;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.emoji-picker-close:hover {
background: #e0e0e0;
}
.comments-section {
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid #f0f0f0;
}
.comments-title {
font-size: 1.1rem;
font-weight: 600;
color: #667eea;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.comment {
background: #f8f9ff;
border-radius: 12px;
padding: 15px;
margin-bottom: 12px;
border-left: 3px solid #667eea;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.comment-author {
font-weight: 600;
color: #667eea;
font-size: 0.95rem;
}
.comment-timestamp {
font-size: 0.8rem;
color: #999;
}
.comment-text {
color: #333;
line-height: 1.5;
font-size: 0.95rem;
margin-bottom: 10px;
}
.comment-reactions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 8px;
}
.comment-reaction-btn {
background: #fff;
border: 1px solid #e0e0e0;
padding: 4px 10px;
border-radius: 15px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
.comment-reaction-btn:hover {
background: #667eea;
border-color: #667eea;
color: white;
transform: scale(1.05);
}
.comment-reaction-count {
font-size: 0.8rem;
font-weight: 600;
}
.add-comment-reaction-btn {
background: white;
border: 1px dashed #ccc;
padding: 4px 10px;
border-radius: 15px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.add-comment-reaction-btn:hover {
border-color: #667eea;
background: #f8f9ff;
}
.comment-form {
margin-top: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.comment-input {
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 0.95rem;
font-family: inherit;
transition: border-color 0.2s;
}
.comment-input:focus {
outline: none;
border-color: #667eea;
}
.comment-textarea {
min-height: 80px;
resize: vertical;
}
.comment-submit {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
align-self: flex-end;
}
.comment-submit:hover {
background: #5568d3;
transform: translateY(-1px);
}
.no-comments {
color: #999;
font-style: italic;
font-size: 0.9rem;
text-align: center;
padding: 15px;
}
.loading { .loading {
text-align: center; text-align: center;
color: white; color: white;
@ -174,6 +477,14 @@
<div id="quotes-container"></div> <div id="quotes-container"></div>
</div> </div>
<div id="emoji-picker-overlay" class="emoji-picker-overlay" onclick="closeEmojiPicker(event)">
<div class="emoji-picker" onclick="event.stopPropagation()">
<div class="emoji-picker-title">Выберите реакцию</div>
<div class="emoji-grid" id="emoji-grid"></div>
<button class="emoji-picker-close" onclick="closeEmojiPicker()">Закрыть</button>
</div>
</div>
<script> <script>
async function loadQuotes() { async function loadQuotes() {
const container = document.getElementById('quotes-container'); const container = document.getElementById('quotes-container');
@ -188,7 +499,7 @@
return; return;
} }
container.innerHTML = quotes.map(q => { container.innerHTML = quotes.map((q, index) => {
const date = new Date(q.timestamp); const date = new Date(q.timestamp);
const formattedDate = date.toLocaleString('ru-RU', { const formattedDate = date.toLocaleString('ru-RU', {
day: '2-digit', day: '2-digit',
@ -198,11 +509,68 @@
minute: '2-digit' minute: '2-digit'
}); });
const reactions = q.reactions || {};
const reactionsHtml = Object.entries(reactions).map(([emoji, count]) =>
`<button class="reaction-btn" onclick="addReaction(${index}, '${emoji}')">
<span>${emoji}</span>
<span class="reaction-count">${count}</span>
</button>`
).join('');
const comments = q.comments || [];
const commentsHtml = comments.length > 0
? comments.map(c => {
const commentDate = new Date(c.timestamp);
const commentFormatted = commentDate.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const commentReactions = c.reactions || {};
const commentReactionsHtml = Object.entries(commentReactions).map(([emoji, count]) =>
`<button class="comment-reaction-btn" onclick="addCommentReaction(${index}, ${c.id}, '${emoji}')">
<span>${emoji}</span>
<span class="comment-reaction-count">${count}</span>
</button>`
).join('');
return `
<div class="comment">
<div class="comment-header">
<span class="comment-author">${escapeHtml(c.author)}</span>
<span class="comment-timestamp">${commentFormatted}</span>
</div>
<div class="comment-text">${escapeHtml(c.text)}</div>
<div class="comment-reactions">
${commentReactionsHtml}
<button class="add-comment-reaction-btn" onclick="showCommentReactionPicker(${index}, ${c.id})"></button>
</div>
</div>
`;
}).join('')
: '<div class="no-comments">Пока нет комментариев. Будьте первым!</div>';
return ` return `
<div class="quote-card"> <div class="quote-card">
<img src="photo_2023-10-27_20-55-45.jpg" alt="Денис Шулепов" style="width: 80px; height: 80px; border-radius: 50%; object-fit: cover; float: left; margin-right: 20px; border: 3px solid #667eea;"> <img src="photo_2023-10-27_20-55-45.jpg" alt="Денис Шулепов" style="width: 80px; height: 80px; border-radius: 50%; object-fit: cover; float: left; margin-right: 20px; border: 3px solid #667eea;">
<div class="quote-text">"${q.quote}"</div> <div class="quote-text">"${q.quote}"</div>
<div class="quote-timestamp">📅 ${formattedDate}</div> <div class="quote-timestamp">📅 ${formattedDate}</div>
<div class="reactions-container">
${reactionsHtml}
<button class="add-reaction-btn" onclick="showReactionPicker(${index})"></button>
</div>
<div class="comments-section">
<div class="comments-title">💬 Комментарии (${comments.length})</div>
${commentsHtml}
<form class="comment-form" onsubmit="addComment(event, ${index})">
<input type="text" class="comment-input" placeholder="Ваше имя" name="author" required maxlength="50">
<textarea class="comment-input comment-textarea" placeholder="Напишите комментарий..." name="text" required maxlength="500"></textarea>
<button type="submit" class="comment-submit">Отправить</button>
</form>
</div>
</div> </div>
`; `;
}).join(''); }).join('');
@ -234,6 +602,141 @@
} }
} }
async function addReaction(index, emoji) {
try {
const response = await fetch(`/api/quotes/${index}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ emoji })
});
if (response.ok) {
loadQuotes();
} else {
throw new Error('Failed to add reaction');
}
} catch (error) {
console.error('Error adding reaction:', error);
alert('❌ Ошибка добавления реакции');
}
}
let currentQuoteIndex = null;
let currentCommentId = null;
let pickerMode = 'quote'; // 'quote' or 'comment'
function showReactionPicker(index) {
currentQuoteIndex = index;
currentCommentId = null;
pickerMode = 'quote';
const emojis = ['❤️', '😍', '🔥', '👏', '🌈', '💅', '✨', '💖', '😂', '👑', '🥰', '💋', '🎉', '⭐', '💜'];
const emojiGrid = document.getElementById('emoji-grid');
emojiGrid.innerHTML = emojis.map(emoji =>
`<button class="emoji-option" onclick="selectEmoji('${emoji}')">${emoji}</button>`
).join('');
document.getElementById('emoji-picker-overlay').classList.add('show');
}
function showCommentReactionPicker(quoteIndex, commentId) {
currentQuoteIndex = quoteIndex;
currentCommentId = commentId;
pickerMode = 'comment';
const emojis = ['❤️', '😍', '🔥', '👏', '🌈', '💅', '✨', '💖', '😂', '👑', '🥰', '💋', '🎉', '⭐', '💜'];
const emojiGrid = document.getElementById('emoji-grid');
emojiGrid.innerHTML = emojis.map(emoji =>
`<button class="emoji-option" onclick="selectEmoji('${emoji}')">${emoji}</button>`
).join('');
document.getElementById('emoji-picker-overlay').classList.add('show');
}
function closeEmojiPicker(event) {
if (event && event.target.id !== 'emoji-picker-overlay') {
return;
}
document.getElementById('emoji-picker-overlay').classList.remove('show');
currentQuoteIndex = null;
currentCommentId = null;
pickerMode = 'quote';
}
function selectEmoji(emoji) {
if (pickerMode === 'quote' && currentQuoteIndex !== null) {
addReaction(currentQuoteIndex, emoji);
} else if (pickerMode === 'comment' && currentQuoteIndex !== null && currentCommentId !== null) {
addCommentReaction(currentQuoteIndex, currentCommentId, emoji);
}
document.getElementById('emoji-picker-overlay').classList.remove('show');
currentQuoteIndex = null;
currentCommentId = null;
pickerMode = 'quote';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function addComment(event, index) {
event.preventDefault();
const form = event.target;
const author = form.author.value.trim();
const text = form.text.value.trim();
if (!author || !text) {
alert('Пожалуйста, заполните все поля');
return;
}
try {
const response = await fetch(`/api/quotes/${index}/comment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ author, text })
});
if (response.ok) {
form.reset();
loadQuotes();
} else {
throw new Error('Failed to add comment');
}
} catch (error) {
console.error('Error adding comment:', error);
alert('❌ Ошибка добавления комментария');
}
}
async function addCommentReaction(quoteIndex, commentId, emoji) {
try {
const response = await fetch(`/api/quotes/${quoteIndex}/comment/${commentId}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ emoji })
});
if (response.ok) {
loadQuotes();
} else {
throw new Error('Failed to add comment reaction');
}
} catch (error) {
console.error('Error adding comment reaction:', error);
alert('❌ Ошибка добавления реакции на комментарий');
}
}
loadQuotes(); loadQuotes();
setInterval(loadQuotes, 60000); setInterval(loadQuotes, 60000);

View File

@ -26,18 +26,19 @@ if (!fs.existsSync(QUOTES_DIR)) {
fs.mkdirSync(QUOTES_DIR, { recursive: true }); fs.mkdirSync(QUOTES_DIR, { recursive: true });
} }
const PROMPT = `Generate a gay pride quote in Russian on behalf of Denis Shulepov. const PROMPT = `Generate an edgy, provocative gay quote in Russian on behalf of Denis Shulepov in the style of Jason Statham's tough-guy quotes.
The quote should be: The quote should be:
- Short and concise (1-3 sentences) - Short and punchy (1-2 sentences max)
- Bold, confident and unapologetic about being gay - Direct, crude and unapologetic
- Celebrate gay culture, freedom and pride - Sexually explicit but clever
- Witty, sarcastic and clever - Dark humor with attitude
- Support LGBTQ+ community and gay lifestyle - Brutally honest about gay life and sex
- Elegant but with attitude
Topics: gay pride, gay culture, fashion, style, relationships, love, freedom, celebrating being gay, self-expression, beauty. Style examples:
- "Каждый не отсосанный мной хуй оставляет шрамы на сердце" - Денис Шулепов ©
- "Лучше три сантиметра сзади, чем 20 спереди"
Use gay slang, humor and vivid imagery. Be bold and confident. The quote should celebrate gay culture and pride with style and elegance. Never use crude street language or insults. Use explicit sexual language, dark humor, and provocative statements. Be crude, direct and shocking. No elegance, no political correctness - just raw, edgy gay humor.
Answer only with the text of the quote, without additional explanations. Answer in Russian!`; Answer only with the text of the quote, without additional explanations. Answer in Russian!`;
@ -122,6 +123,150 @@ app.post('/api/generate', async (req, res) => {
} }
}); });
app.post('/api/quotes/:index/react', (req, res) => {
try {
const { index } = req.params;
const { emoji } = req.body;
if (!emoji) {
return res.status(400).json({ error: 'Emoji is required' });
}
const quotesFile = path.join(QUOTES_DIR, 'quotes.json');
if (!fs.existsSync(quotesFile)) {
return res.status(404).json({ error: 'Quotes not found' });
}
const fileContent = fs.readFileSync(quotesFile, 'utf8');
const quotes = JSON.parse(fileContent);
// Конвертируем индекс из reversed массива в оригинальный
const actualIndex = quotes.length - 1 - parseInt(index);
if (actualIndex < 0 || actualIndex >= quotes.length) {
return res.status(404).json({ error: 'Quote not found' });
}
if (!quotes[actualIndex].reactions) {
quotes[actualIndex].reactions = {};
}
if (!quotes[actualIndex].reactions[emoji]) {
quotes[actualIndex].reactions[emoji] = 0;
}
quotes[actualIndex].reactions[emoji]++;
fs.writeFileSync(quotesFile, JSON.stringify(quotes, null, 2));
res.json({ success: true, reactions: quotes[actualIndex].reactions });
} catch (error) {
console.error('Error adding reaction:', error);
res.status(500).json({ error: 'Failed to add reaction' });
}
});
app.post('/api/quotes/:index/comment', (req, res) => {
try {
const { index } = req.params;
const { author, text } = req.body;
if (!author || !text) {
return res.status(400).json({ error: 'Author and text are required' });
}
const quotesFile = path.join(QUOTES_DIR, 'quotes.json');
if (!fs.existsSync(quotesFile)) {
return res.status(404).json({ error: 'Quotes not found' });
}
const fileContent = fs.readFileSync(quotesFile, 'utf8');
const quotes = JSON.parse(fileContent);
const actualIndex = quotes.length - 1 - parseInt(index);
if (actualIndex < 0 || actualIndex >= quotes.length) {
return res.status(404).json({ error: 'Quote not found' });
}
if (!quotes[actualIndex].comments) {
quotes[actualIndex].comments = [];
}
const comment = {
id: Date.now(),
author: author.trim(),
text: text.trim(),
timestamp: new Date().toISOString(),
reactions: {}
};
quotes[actualIndex].comments.push(comment);
fs.writeFileSync(quotesFile, JSON.stringify(quotes, null, 2));
res.json({ success: true, comment });
} catch (error) {
console.error('Error adding comment:', error);
res.status(500).json({ error: 'Failed to add comment' });
}
});
app.post('/api/quotes/:index/comment/:commentId/react', (req, res) => {
try {
const { index, commentId } = req.params;
const { emoji } = req.body;
if (!emoji) {
return res.status(400).json({ error: 'Emoji is required' });
}
const quotesFile = path.join(QUOTES_DIR, 'quotes.json');
if (!fs.existsSync(quotesFile)) {
return res.status(404).json({ error: 'Quotes not found' });
}
const fileContent = fs.readFileSync(quotesFile, 'utf8');
const quotes = JSON.parse(fileContent);
const actualIndex = quotes.length - 1 - parseInt(index);
if (actualIndex < 0 || actualIndex >= quotes.length) {
return res.status(404).json({ error: 'Quote not found' });
}
if (!quotes[actualIndex].comments) {
return res.status(404).json({ error: 'No comments found' });
}
const comment = quotes[actualIndex].comments.find(c => c.id === parseInt(commentId));
if (!comment) {
return res.status(404).json({ error: 'Comment not found' });
}
if (!comment.reactions) {
comment.reactions = {};
}
if (!comment.reactions[emoji]) {
comment.reactions[emoji] = 0;
}
comment.reactions[emoji]++;
fs.writeFileSync(quotesFile, JSON.stringify(quotes, null, 2));
res.json({ success: true, reactions: comment.reactions });
} catch (error) {
console.error('Error adding comment reaction:', error);
res.status(500).json({ error: 'Failed to add comment reaction' });
}
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);
}); });