Initial
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
*.png
|
||||||
|
cache/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
cache/
|
||||||
|
*.png
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AskMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Ask2AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/jsLibraryMappings.xml
generated
Normal file
6
.idea/jsLibraryMappings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptLibraryMappings">
|
||||||
|
<includedPredefinedLibrary name="Node.js Core" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/quotegen.iml" filepath="$PROJECT_DIR$/.idea/quotegen.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
.idea/quotegen.iml
generated
Normal file
12
.idea/quotegen.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
FROM node:18-slim
|
||||||
|
|
||||||
|
# Install dependencies for Puppeteer/Chrome
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
wget \
|
||||||
|
gnupg \
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
|
libasound2 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatspi2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libdrm2 \
|
||||||
|
libgbm1 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libwayland-client0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxrandr2 \
|
||||||
|
xdg-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "api.js"]
|
||||||
167
api.js
Normal file
167
api.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const nodeHtmlToImage = require('node-html-to-image');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 3000;
|
||||||
|
const CACHE_DIR = path.join(__dirname, 'cache');
|
||||||
|
|
||||||
|
// Create cache directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(CACHE_DIR)) {
|
||||||
|
fs.mkdirSync(CACHE_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
function hashConfig(config) {
|
||||||
|
const configString = JSON.stringify(config);
|
||||||
|
return crypto.createHash('sha256').update(configString).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachePath(hash) {
|
||||||
|
return path.join(CACHE_DIR, `${hash}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedImage(config) {
|
||||||
|
const hash = hashConfig(config);
|
||||||
|
const cachePath = getCachePath(hash);
|
||||||
|
|
||||||
|
if (fs.existsSync(cachePath)) {
|
||||||
|
return fs.readFileSync(cachePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheImage(config, imageBuffer) {
|
||||||
|
const hash = hashConfig(config);
|
||||||
|
const cachePath = getCachePath(hash);
|
||||||
|
fs.writeFileSync(cachePath, imageBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(epoch) {
|
||||||
|
const date = new Date(epoch * 1000);
|
||||||
|
|
||||||
|
const hours = date.getHours();
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const hour12 = hours % 12 || 12;
|
||||||
|
|
||||||
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
const month = months[date.getMonth()];
|
||||||
|
const day = date.getDate();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
return `${hour12}:${minutes} ${ampm} · ${month} ${day}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateQuoteBuffer(config) {
|
||||||
|
const htmlTemplate = fs.readFileSync(path.join(__dirname, 'quote.html'), 'utf8');
|
||||||
|
|
||||||
|
const configScript = `
|
||||||
|
<script>
|
||||||
|
window.tweetConfig = ${JSON.stringify(config)};
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const modifiedHtml = htmlTemplate.replace('</head>', `${configScript}</head>`);
|
||||||
|
|
||||||
|
const image = await nodeHtmlToImage({
|
||||||
|
html: modifiedHtml,
|
||||||
|
puppeteerArgs: {
|
||||||
|
args: ['--no-sandbox'],
|
||||||
|
defaultViewport: {
|
||||||
|
width: 3240,
|
||||||
|
height: 3240
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeScreenshot: async (page) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET endpoint - use query parameters
|
||||||
|
app.get('/generate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timestamp = req.query.timestamp ? formatTimestamp(parseInt(req.query.timestamp)) : formatTimestamp(Date.now() / 1000);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
displayName: req.query.displayName || "Anonymous",
|
||||||
|
username: req.query.username || "@anonymous",
|
||||||
|
avatarUrl: req.query.avatarUrl || "",
|
||||||
|
text: req.query.text || "No text provided",
|
||||||
|
imageUrl: req.query.imageUrl || null,
|
||||||
|
timestamp: timestamp,
|
||||||
|
viewsCount: req.query.viewsCount || "0"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let image = getCachedImage(config);
|
||||||
|
let fromCache = true;
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
// Generate new image
|
||||||
|
image = await generateQuoteBuffer(config);
|
||||||
|
cacheImage(config, image);
|
||||||
|
fromCache = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
res.setHeader('X-Cache', fromCache ? 'HIT' : 'MISS');
|
||||||
|
res.send(image);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ error: 'Failed to generate image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST endpoint - use request body
|
||||||
|
app.post('/generate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timestamp = req.body.timestamp ? formatTimestamp(parseInt(req.body.timestamp)) : formatTimestamp(Date.now() / 1000);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
displayName: req.body.displayName || "Anonymous",
|
||||||
|
username: req.body.username || "@anonymous",
|
||||||
|
avatarUrl: req.body.avatarUrl || "",
|
||||||
|
text: req.body.text || "No text provided",
|
||||||
|
imageUrl: req.body.imageUrl || null,
|
||||||
|
timestamp: timestamp,
|
||||||
|
viewsCount: req.body.viewsCount || "0"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let image = getCachedImage(config);
|
||||||
|
let fromCache = true;
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
// Generate new image
|
||||||
|
image = await generateQuoteBuffer(config);
|
||||||
|
cacheImage(config, image);
|
||||||
|
fromCache = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
res.setHeader('X-Cache', fromCache ? 'HIT' : 'MISS');
|
||||||
|
res.send(image);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ error: 'Failed to generate image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Quote generator API running on http://localhost:${PORT}`);
|
||||||
|
console.log(`GET: http://localhost:${PORT}/generate?text=Hello&displayName=Test&username=@test×tamp=1735574400`);
|
||||||
|
console.log(`POST: http://localhost:${PORT}/generate`);
|
||||||
|
});
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
quotegen:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
62
generate.js
Normal file
62
generate.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const nodeHtmlToImage = require('node-html-to-image');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function generateQuote(config, outputPath = 'quote.png') {
|
||||||
|
const htmlTemplate = fs.readFileSync(path.join(__dirname, 'quote.html'), 'utf8');
|
||||||
|
|
||||||
|
const configScript = `
|
||||||
|
<script>
|
||||||
|
window.tweetConfig = ${JSON.stringify(config)};
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const modifiedHtml = htmlTemplate.replace('</head>', `${configScript}</head>`);
|
||||||
|
|
||||||
|
await nodeHtmlToImage({
|
||||||
|
output: outputPath,
|
||||||
|
html: modifiedHtml,
|
||||||
|
puppeteerArgs: {
|
||||||
|
args: ['--no-sandbox'],
|
||||||
|
defaultViewport: {
|
||||||
|
width: 3240,
|
||||||
|
height: 3240
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeScreenshot: async (page) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Quote generated: ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Example usage
|
||||||
|
const exampleTweet = {
|
||||||
|
displayName: "Geoff Marshall",
|
||||||
|
username: "@geofftech",
|
||||||
|
avatarUrl: "https://pbs.twimg.com/profile_images/1295490618140110848/Fu4chISB_x96.jpg",
|
||||||
|
text: "Does anyone else find it immensely satisfying when you turn a pocket inside out and get rid of the crumbs and fluff stuck in the bottom.",
|
||||||
|
imageUrl: null,
|
||||||
|
timestamp: "10:04 AM · Jul 11, 2017",
|
||||||
|
viewsCount: "128K"
|
||||||
|
};
|
||||||
|
|
||||||
|
const exampleTweetWithImage = {
|
||||||
|
displayName: "miss katie",
|
||||||
|
username: "@katiopolis",
|
||||||
|
avatarUrl: "https://pbs.twimg.com/profile_images/2004569144893554688/KaYjqylC_x96.jpg",
|
||||||
|
text: "omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much",
|
||||||
|
imageUrl: "https://pbs.twimg.com/media/G9Wtk1xWcAA-WMZ?format=jpg&name=large",
|
||||||
|
timestamp: "5:58 PM · Dec 29, 2025",
|
||||||
|
viewsCount: "1.1M"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate both examples
|
||||||
|
(async () => {
|
||||||
|
await generateQuote(exampleTweet, 'quote-no-image.png');
|
||||||
|
await generateQuote(exampleTweetWithImage, 'quote-with-image.png');
|
||||||
|
})();
|
||||||
|
|
||||||
|
module.exports = { generateQuote };
|
||||||
2140
package-lock.json
generated
Normal file
2140
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "quotegen",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"generate": "node generate.js",
|
||||||
|
"start": "node api.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"node-html-to-image": "^5.0.0",
|
||||||
|
"express": "^4.18.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
320
quote.html
Normal file
320
quote.html
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Quote Generator</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square-container {
|
||||||
|
width: calc(100vmin - 20px);
|
||||||
|
height: calc(100vmin - 20px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-container {
|
||||||
|
width: 450px;
|
||||||
|
background-color: rgb(0, 0, 0);
|
||||||
|
/* border: 1px solid rgb(47, 51, 54); */
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: rgb(51, 54, 57);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-header-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgb(231, 233, 234);
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgb(113, 118, 123);
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-text {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgb(231, 233, 234);
|
||||||
|
line-height: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-image-container {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(47, 51, 54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-image {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-metadata {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-time {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgb(113, 118, 123);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-dot {
|
||||||
|
color: rgb(113, 118, 123);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-views {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.views-count {
|
||||||
|
color: rgb(231, 233, 234);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.views-label {
|
||||||
|
color: rgb(113, 118, 123);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tweet-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgb(113, 118, 123);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
color: rgb(29, 155, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
width: 18.75px;
|
||||||
|
height: 18.75px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="square-container">
|
||||||
|
<div class="tweet-container">
|
||||||
|
<div class="tweet-header">
|
||||||
|
<div class="avatar-container">
|
||||||
|
<div class="avatar" id="avatar">
|
||||||
|
<div class="avatar-placeholder" id="avatarPlaceholder" style="display: none;">
|
||||||
|
<div class="css-175oi2r r-sdzlij r-1udh08x r-45ll9u r-u8s1d r-1v2oles r-176fswd" style="width: calc(100% - 4px); height: calc(100% - 4px);">
|
||||||
|
<div class="css-175oi2r r-172uzmj r-1pi2tsx r-13qz1uu r-1ny4l3l"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tweet-header-info">
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="display-name" id="displayName">miss katie</span>
|
||||||
|
<span class="username" id="username">@katiopolis</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tweet-text" id="tweetText">omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much</div>
|
||||||
|
|
||||||
|
<div class="tweet-image-container" id="imageContainer">
|
||||||
|
<img src="https://pbs.twimg.com/media/G9Wtk1xWcAA-WMZ?format=jpg&name=large" alt="Image" class="tweet-image" id="tweetImage">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tweet-metadata">
|
||||||
|
<a href="#" class="tweet-time" id="tweetTime">5:58 PM · Dec 29, 2025</a>
|
||||||
|
<!-- <span class="metadata-dot">·</span>
|
||||||
|
<span class="tweet-views">
|
||||||
|
<span class="views-count" id="viewsCount">1.1M</span> <span class="views-label">Views</span>
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tweet-actions">
|
||||||
|
<button class="action-button">
|
||||||
|
<svg viewBox="0 0 24 24" class="action-icon">
|
||||||
|
<g><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"></path></g>
|
||||||
|
</svg>
|
||||||
|
<span>134</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="action-button">
|
||||||
|
<svg viewBox="0 0 24 24" class="action-icon">
|
||||||
|
<g><path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"></path></g>
|
||||||
|
</svg>
|
||||||
|
<span>3.3K</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="action-button">
|
||||||
|
<svg viewBox="0 0 24 24" class="action-icon">
|
||||||
|
<g><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"></path></g>
|
||||||
|
</svg>
|
||||||
|
<span>111K</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="action-button">
|
||||||
|
<svg viewBox="0 0 24 24" class="action-icon">
|
||||||
|
<g><path d="M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z"></path></g>
|
||||||
|
</svg>
|
||||||
|
<span>2.2K</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="action-button">
|
||||||
|
<svg viewBox="0 0 24 24" class="action-icon">
|
||||||
|
<g><path d="M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"></path></g>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Configuration object - set this via Puppeteer
|
||||||
|
window.tweetConfig = window.tweetConfig || {
|
||||||
|
displayName: "miss katie",
|
||||||
|
username: "@katiopolis",
|
||||||
|
avatarUrl: "https://pbs.twimg.com/profile_images/2004569144893554688/KaYjqylC_x96.jpg",
|
||||||
|
text: "omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much",
|
||||||
|
imageUrl: "https://pbs.twimg.com/media/G9Wtk1xWcAA-WMZ?format=jpg&name=large",
|
||||||
|
timestamp: "5:58 PM · Dec 29, 2025",
|
||||||
|
viewsCount: "1.1M"
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadTweetData() {
|
||||||
|
const config = window.tweetConfig;
|
||||||
|
|
||||||
|
document.getElementById('displayName').textContent = config.displayName;
|
||||||
|
document.getElementById('username').textContent = config.username;
|
||||||
|
document.getElementById('tweetText').textContent = config.text;
|
||||||
|
document.getElementById('tweetTime').textContent = config.timestamp;
|
||||||
|
document.getElementById('viewsCount').textContent = config.viewsCount;
|
||||||
|
|
||||||
|
const avatar = document.getElementById('avatar');
|
||||||
|
const avatarPlaceholder = document.getElementById('avatarPlaceholder');
|
||||||
|
|
||||||
|
if (config.avatarUrl) {
|
||||||
|
avatar.style.backgroundImage = `url("${config.avatarUrl}")`;
|
||||||
|
avatarPlaceholder.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
avatar.style.backgroundImage = 'none';
|
||||||
|
avatarPlaceholder.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageContainer = document.getElementById('imageContainer');
|
||||||
|
const tweetImage = document.getElementById('tweetImage');
|
||||||
|
|
||||||
|
if (config.imageUrl) {
|
||||||
|
tweetImage.src = config.imageUrl;
|
||||||
|
imageContainer.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
imageContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitTweetToSquare() {
|
||||||
|
const square = document.querySelector('.square-container');
|
||||||
|
const tweet = document.querySelector('.tweet-container');
|
||||||
|
|
||||||
|
const squareWidth = square.offsetWidth;
|
||||||
|
const squareHeight = square.offsetHeight;
|
||||||
|
|
||||||
|
tweet.style.transform = 'none';
|
||||||
|
|
||||||
|
const tweetWidth = tweet.offsetWidth;
|
||||||
|
const tweetHeight = tweet.offsetHeight;
|
||||||
|
|
||||||
|
const scaleX = squareWidth / tweetWidth;
|
||||||
|
const scaleY = squareHeight / tweetHeight;
|
||||||
|
const scale = Math.min(scaleX, scaleY) * 0.95;
|
||||||
|
|
||||||
|
tweet.style.transform = `scale(${scale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
loadTweetData();
|
||||||
|
setTimeout(fitTweetToSquare, 100);
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', fitTweetToSquare);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user